Authorize consents for the account-holder type the bank supports
Kontist authorized but shared no accounts: psu_type was hardcoded to personal, and Enable Banking documents that a psu_type mismatch can yield a consent without the expected accounts. The bank listing now reports each institution's supported psu_types, the connect form offers only those, the chosen type reaches POST /auth, and an unsupported combination is refused before the user is sent to a bank. The choice is stored per consent so reconnecting reuses it; consents predating the choice stay personal. Also repairs the frontend derivation, which the Montserrat dependency broke: npmDepsHash was stale and web/public was missing from the fileset, so the traced duck icon never reached the built assets.
This commit is contained in:
@@ -60,7 +60,7 @@ func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redi
|
|||||||
}
|
}
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/aspsps":
|
case "/aspsps":
|
||||||
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
|
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}]}`)
|
||||||
case "/auth":
|
case "/auth":
|
||||||
var req struct {
|
var req struct {
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
@@ -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", 12); err != nil {
|
if _, err := a.Authorize(context.Background(), "N26", "DE", banking.PSUPersonal, 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", 12); err == nil {
|
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 12); err == nil {
|
||||||
t.Fatal("disabled saved configuration fell back to environment")
|
t.Fatal("disabled saved configuration fell back to environment")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -14,11 +14,16 @@ type authorization struct {
|
|||||||
Expires time.Time
|
Expires time.Time
|
||||||
Institution string
|
Institution string
|
||||||
Country string
|
Country string
|
||||||
|
PSUType string
|
||||||
HistoryMonths int
|
HistoryMonths int
|
||||||
}
|
}
|
||||||
type Consent struct {
|
type Consent struct {
|
||||||
Institution string `json:"institution"`
|
Institution string `json:"institution"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
|
// PSUType records the account-holder kind this consent was authorized for
|
||||||
|
// so reconnecting reuses it: a business account authorized as personal
|
||||||
|
// shares no accounts.
|
||||||
|
PSUType string `json:"psu_type,omitempty"`
|
||||||
HistoryMonths int `json:"history_months"`
|
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"`
|
||||||
@@ -27,12 +32,22 @@ 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"`
|
||||||
|
PSUType string `json:"psu_type"`
|
||||||
HistoryMonths int `json:"history_months"`
|
HistoryMonths int `json:"history_months"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ValidUntil string `json:"valid_until"`
|
ValidUntil string `json:"valid_until"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// psuType keeps legacy consents, which predate the choice, on the personal
|
||||||
|
// flow they were originally authorized with.
|
||||||
|
func (c Consent) psuType() string {
|
||||||
|
if !banking.ValidPSUType(c.PSUType) {
|
||||||
|
return banking.PSUPersonal
|
||||||
|
}
|
||||||
|
return c.PSUType
|
||||||
|
}
|
||||||
|
|
||||||
func (c Consent) historyMonths() int {
|
func (c Consent) historyMonths() int {
|
||||||
if c.HistoryMonths == 0 {
|
if c.HistoryMonths == 0 {
|
||||||
return defaultHistoryMonths
|
return defaultHistoryMonths
|
||||||
@@ -43,7 +58,7 @@ func (c Consent) historyMonths() int {
|
|||||||
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", HistoryMonths: defaultHistoryMonths, Status: "local"}
|
c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", PSUType: banking.PSUPersonal, 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"
|
||||||
@@ -55,6 +70,7 @@ func (a *App) connections(d domain.Dataset) []Connection {
|
|||||||
}
|
}
|
||||||
meta := a.ops.Consents[session.ID]
|
meta := a.ops.Consents[session.ID]
|
||||||
c.HistoryMonths = meta.historyMonths()
|
c.HistoryMonths = meta.historyMonths()
|
||||||
|
c.PSUType = meta.psuType()
|
||||||
if meta.Institution != "" {
|
if meta.Institution != "" {
|
||||||
c.Institution = meta.Institution
|
c.Institution = meta.Institution
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -17,9 +18,11 @@ type historyBank struct {
|
|||||||
authState string
|
authState string
|
||||||
authorizations int
|
authorizations int
|
||||||
fromDates []string
|
fromDates []string
|
||||||
|
psuTypes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, error) {
|
func (b *historyBank) Authorize(_ context.Context, _, _, psuType, state string) (string, error) {
|
||||||
|
b.psuTypes = append(b.psuTypes, psuType)
|
||||||
b.authState = state
|
b.authState = state
|
||||||
b.authorizations++
|
b.authorizations++
|
||||||
return "https://bank.example/authorize", nil
|
return "https://bank.example/authorize", nil
|
||||||
@@ -87,14 +90,14 @@ func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing.
|
|||||||
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "history_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}}
|
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
|
a.bank = b
|
||||||
for _, months := range []int{-1, 0, 121} {
|
for _, months := range []int{-1, 0, 121} {
|
||||||
if _, err := a.Authorize(ctx, "N26", "DE", months); err == nil {
|
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, months); err == nil {
|
||||||
t.Fatalf("accepted invalid history choice %d", months)
|
t.Fatalf("accepted invalid history choice %d", months)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if b.authorizations != 0 {
|
if b.authorizations != 0 {
|
||||||
t.Fatal("invalid history choice reached the bank")
|
t.Fatal("invalid history choice reached the bank")
|
||||||
}
|
}
|
||||||
if _, err := a.Authorize(ctx, "N26", "DE", 24); err != nil {
|
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 24); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
||||||
@@ -257,7 +260,7 @@ func TestDeletedBankAccountIsNotResurrectedByLaterConnectOrSync(t *testing.T) {
|
|||||||
// A later connect for a different bank triggers binding recovery.
|
// A later connect for a different bank triggers binding recovery.
|
||||||
other := domain.Account{ID: "ing_acct", DisplayName: "ING Giro", Institution: "ING", Currency: "EUR", ExternalAccountID: "ing_uid", Active: true}
|
other := domain.Account{ID: "ing_acct", DisplayName: "ING Giro", Institution: "ING", Currency: "EUR", ExternalAccountID: "ing_uid", Active: true}
|
||||||
b.session = banking.Session{ID: "ing_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{other}}
|
b.session = banking.Session{ID: "ing_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{other}}
|
||||||
if _, err := a.Authorize(ctx, "ING", "DE", 12); err != nil {
|
if _, err := a.Authorize(ctx, "ING", "DE", banking.PSUPersonal, 12); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
||||||
@@ -293,7 +296,7 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "empty_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}}
|
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "empty_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}}
|
||||||
a.bank = b
|
a.bank = b
|
||||||
if _, err := a.Authorize(ctx, "Kontist", "DE", 12); err != nil {
|
if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := a.Callback(ctx, "one_time_code", b.authState)
|
_, err := a.Callback(ctx, "one_time_code", b.authState)
|
||||||
@@ -313,3 +316,48 @@ func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A business consent must stay business: reconnecting a business account with
|
||||||
|
// the personal flow authorizes a consent that shares no accounts.
|
||||||
|
func TestSavedAccountHolderTypeSurvivesRestartForReconnect(t *testing.T) {
|
||||||
|
a, s := testApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
account := s.Data.Accounts[0]
|
||||||
|
account.ExternalAccountID = "kontist_uid"
|
||||||
|
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "kontist_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}}
|
||||||
|
a.bank = b
|
||||||
|
if _, err := a.Authorize(ctx, "Kontist", "DE", banking.PSUBusiness, 12); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(b.psuTypes, []string{banking.PSUBusiness}) {
|
||||||
|
t.Fatalf("chosen account type did not reach the provider: %q", b.psuTypes)
|
||||||
|
}
|
||||||
|
a = reopenBankingApp(t, a)
|
||||||
|
a.bank = b
|
||||||
|
after, err := a.Snapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(after.Connections) != 1 || after.Connections[0].PSUType != banking.PSUBusiness {
|
||||||
|
t.Fatalf("account type unavailable for reconnecting: %+v", after.Connections)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"corporate", "Personal"} {
|
||||||
|
if _, err := a.Authorize(ctx, "Kontist", "DE", invalid, 12); err == nil {
|
||||||
|
t.Fatalf("accepted undocumented account type %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Legacy consents predate the choice and stay on the personal flow.
|
||||||
|
meta := a.ops.Consents["kontist_session"]
|
||||||
|
meta.PSUType = ""
|
||||||
|
a.ops.Consents["kontist_session"] = meta
|
||||||
|
legacy, err := a.Snapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if legacy.Connections[0].PSUType != banking.PSUPersonal {
|
||||||
|
t.Fatalf("legacy consent lost its personal default: %+v", legacy.Connections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+20
-6
@@ -174,7 +174,10 @@ func (a *App) Backfill(ctx context.Context, rev, accountID string, historyMonths
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
func (a *App) Authorize(ctx context.Context, institution, country string, historyMonths int) (string, error) {
|
|
||||||
|
// Authorize starts a consent for one account-holder kind. An empty psuType
|
||||||
|
// keeps the previous personal default for existing API callers.
|
||||||
|
func (a *App) Authorize(ctx context.Context, institution, country, psuType string, historyMonths int) (string, error) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
if historyMonths < 1 || historyMonths > 120 {
|
if historyMonths < 1 || historyMonths > 120 {
|
||||||
@@ -191,17 +194,24 @@ func (a *App) Authorize(ctx context.Context, institution, country string, histor
|
|||||||
if len(country) != 2 {
|
if len(country) != 2 {
|
||||||
return "", errors.New("country must be a two-letter code")
|
return "", errors.New("country must be a two-letter code")
|
||||||
}
|
}
|
||||||
|
if psuType == "" {
|
||||||
|
psuType = banking.PSUPersonal
|
||||||
|
}
|
||||||
|
if !banking.ValidPSUType(psuType) {
|
||||||
|
return "", errors.New("account type must be personal or business")
|
||||||
|
}
|
||||||
for state, auth := range a.authStates {
|
for state, auth := range a.authStates {
|
||||||
if time.Now().After(auth.Expires) {
|
if time.Now().After(auth.Expires) {
|
||||||
delete(a.authStates, state)
|
delete(a.authStates, state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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, psuType, state)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, HistoryMonths: historyMonths}
|
return "", bankFailure(err, "bank authorization unavailable; retry connecting")
|
||||||
}
|
}
|
||||||
return url, err
|
a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, PSUType: psuType, HistoryMonths: historyMonths}
|
||||||
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Institutions lists connectable banks for the country so the UI can offer
|
// Institutions lists connectable banks for the country so the UI can offer
|
||||||
@@ -276,7 +286,7 @@ func (a *App) Callback(ctx context.Context, code, state string) (int, error) {
|
|||||||
return 0, errors.New("the bank authorized the connection but shared no accounts, so nothing was linked; accounts of another type (for example business) may need a separate consent")
|
return 0, errors.New("the bank authorized the connection but shared no accounts, so nothing was linked; accounts of another type (for example business) may need a separate consent")
|
||||||
}
|
}
|
||||||
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, HistoryMonths: auth.HistoryMonths}
|
a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, PSUType: auth.PSUType, HistoryMonths: auth.HistoryMonths}
|
||||||
if err = a.saveOps(); err != nil {
|
if err = a.saveOps(); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -359,6 +369,10 @@ func bankFailure(err error, fallback string) error {
|
|||||||
if errors.As(err, &api) {
|
if errors.As(err, &api) {
|
||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
|
var consent *banking.ConsentError
|
||||||
|
if errors.As(err, &consent) {
|
||||||
|
return consent
|
||||||
|
}
|
||||||
return errors.New(fallback)
|
return errors.New(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type bankScenario struct {
|
|||||||
fail bool
|
fail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) {
|
func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) {
|
||||||
return "https://bank.example/authorize", nil
|
return "https://bank.example/authorize", nil
|
||||||
}
|
}
|
||||||
func (b *bankScenario) Institutions(context.Context, string) ([]banking.Institution, error) {
|
func (b *bankScenario) Institutions(context.Context, string) ([]banking.Institution, error) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type Balance struct {
|
|||||||
ReferenceDate string `json:"reference_date,omitempty"`
|
ReferenceDate string `json:"reference_date,omitempty"`
|
||||||
}
|
}
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Authorize(context.Context, string, string, string) (string, error)
|
Authorize(ctx context.Context, institution, country, psuType, state string) (string, error)
|
||||||
Exchange(context.Context, string) (Session, error)
|
Exchange(context.Context, string) (Session, error)
|
||||||
Status(context.Context, string) (SessionStatus, error)
|
Status(context.Context, string) (SessionStatus, error)
|
||||||
Balances(context.Context, string) ([]Balance, error)
|
Balances(context.Context, string) ([]Balance, error)
|
||||||
@@ -457,13 +457,29 @@ func (a accountDTO) account(institution string) (domain.Account, error) {
|
|||||||
return domain.Account{ID: "acct_" + digest("enablebanking", stable), DisplayName: name, Institution: institution, Currency: a.Currency, ExternalAccountID: a.UID, IBAN: normalizeIBAN(a.AccountID.IBAN), Active: a.UID != ""}, nil
|
return domain.Account{ID: "acct_" + digest("enablebanking", stable), DisplayName: name, Institution: institution, Currency: a.Currency, ExternalAccountID: a.UID, IBAN: normalizeIBAN(a.AccountID.IBAN), Active: a.UID != ""}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Institution describes a bank available for personal account information.
|
// PSU types are the documented account-holder kinds. Enable Banking warns that
|
||||||
// Logo is retained only when it is an https Enable Banking URL, matching the
|
// a psu_type mismatch can authorize a consent that shares no accounts, so the
|
||||||
|
// caller must choose one the bank actually supports.
|
||||||
|
const (
|
||||||
|
PSUPersonal = "personal"
|
||||||
|
PSUBusiness = "business"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidPSUType reports whether s is a documented Enable Banking PSU type.
|
||||||
|
func ValidPSUType(s string) bool {
|
||||||
|
return s == PSUPersonal || s == PSUBusiness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Institution describes a bank available for account information. Logo is
|
||||||
|
// retained only when it is an https Enable Banking URL, matching the
|
||||||
// Content-Security-Policy image allowlist under which the UI displays it.
|
// Content-Security-Policy image allowlist under which the UI displays it.
|
||||||
|
// PSUTypes lists the account-holder kinds this bank supports; a business-only
|
||||||
|
// bank authorized as personal shares no accounts.
|
||||||
type Institution struct {
|
type Institution struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
Logo string `json:"logo,omitempty"`
|
Logo string `json:"logo,omitempty"`
|
||||||
|
PSUTypes []string `json:"psu_types"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeLogoURL admits only https Enable Banking brand URLs. Any other
|
// safeLogoURL admits only https Enable Banking brand URLs. Any other
|
||||||
@@ -482,23 +498,38 @@ func safeLogoURL(logo string) string {
|
|||||||
|
|
||||||
type aspspDTO struct {
|
type aspspDTO struct {
|
||||||
institutionDTO
|
institutionDTO
|
||||||
Logo string `json:"logo"`
|
Logo string `json:"logo"`
|
||||||
MaximumConsentValidity int64 `json:"maximum_consent_validity"`
|
PSUTypes []string `json:"psu_types"`
|
||||||
|
MaximumConsentValidity int64 `json:"maximum_consent_validity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// psuTypes keeps only documented values, preserving personal before business
|
||||||
|
// so callers can offer a stable default.
|
||||||
|
func (a aspspDTO) psuTypes() []string {
|
||||||
|
out := make([]string, 0, 2)
|
||||||
|
for _, kind := range []string{PSUPersonal, PSUBusiness} {
|
||||||
|
if slices.Contains(a.PSUTypes, kind) {
|
||||||
|
out = append(out, kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// aspsps lists account-information banks for a country without filtering by
|
||||||
|
// PSU type: each entry reports the types it supports.
|
||||||
func (p *EnableBanking) aspsps(ctx context.Context, country string) ([]aspspDTO, error) {
|
func (p *EnableBanking) aspsps(ctx context.Context, country string) ([]aspspDTO, error) {
|
||||||
var list struct {
|
var list struct {
|
||||||
ASPSPs []aspspDTO `json:"aspsps"`
|
ASPSPs []aspspDTO `json:"aspsps"`
|
||||||
}
|
}
|
||||||
query := url.Values{"country": {country}, "psu_type": {"personal"}, "service": {"AIS"}}
|
query := url.Values{"country": {country}, "service": {"AIS"}}
|
||||||
if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil {
|
if err := p.request(ctx, http.MethodGet, "/aspsps?"+query.Encode(), nil, &list); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return list.ASPSPs, nil
|
return list.ASPSPs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Institutions lists the banks connectable for personal account information
|
// Institutions lists the banks connectable for account information in a
|
||||||
// in a country, excluding entries the Authorize flow would reject anyway.
|
// country, excluding entries the Authorize flow would reject anyway.
|
||||||
func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Institution, error) {
|
func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Institution, error) {
|
||||||
country = strings.ToUpper(strings.TrimSpace(country))
|
country = strings.ToUpper(strings.TrimSpace(country))
|
||||||
if len(country) != 2 {
|
if len(country) != 2 {
|
||||||
@@ -510,34 +541,53 @@ func (p *EnableBanking) Institutions(ctx context.Context, country string) ([]Ins
|
|||||||
}
|
}
|
||||||
result := make([]Institution, 0, len(aspsps))
|
result := make([]Institution, 0, len(aspsps))
|
||||||
for _, a := range aspsps {
|
for _, a := range aspsps {
|
||||||
if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 {
|
kinds := a.psuTypes()
|
||||||
|
if a.Name == "" || a.Country != country || a.MaximumConsentValidity <= 0 || len(kinds) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo)})
|
result = append(result, Institution{Name: a.Name, Country: a.Country, Logo: safeLogoURL(a.Logo), PSUTypes: kinds})
|
||||||
}
|
}
|
||||||
slices.SortFunc(result, func(a, b Institution) int { return strings.Compare(a.Name, b.Name) })
|
slices.SortFunc(result, func(a, b Institution) int { return strings.Compare(a.Name, b.Name) })
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *EnableBanking) Authorize(ctx context.Context, institution, country, state string) (string, error) {
|
// ConsentError explains why a consent cannot be started. Its message is built
|
||||||
|
// only from the caller's own request and the documented ASPSP listing fields,
|
||||||
|
// never from provider response text, so callers may show it to the user.
|
||||||
|
type ConsentError struct{ message string }
|
||||||
|
|
||||||
|
func (e *ConsentError) Error() string { return e.message }
|
||||||
|
|
||||||
|
// Authorize starts a consent for the given account-holder kind. psuType must
|
||||||
|
// be one the bank supports: Enable Banking documents that a mismatch can
|
||||||
|
// authorize a consent that then shares no accounts.
|
||||||
|
func (p *EnableBanking) Authorize(ctx context.Context, institution, country, psuType, state string) (string, error) {
|
||||||
country = strings.ToUpper(strings.TrimSpace(country))
|
country = strings.ToUpper(strings.TrimSpace(country))
|
||||||
institution = strings.TrimSpace(institution)
|
institution = strings.TrimSpace(institution)
|
||||||
if institution == "" || len(country) != 2 || state == "" {
|
if institution == "" || len(country) != 2 || state == "" {
|
||||||
return "", fmt.Errorf("institution, country and authorization state are required")
|
return "", fmt.Errorf("institution, country and authorization state are required")
|
||||||
}
|
}
|
||||||
|
if !ValidPSUType(psuType) {
|
||||||
|
return "", fmt.Errorf("account type must be personal or business")
|
||||||
|
}
|
||||||
aspsps, err := p.aspsps(ctx, country)
|
aspsps, err := p.aspsps(ctx, country)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var validity int64
|
var validity int64
|
||||||
|
var supported []string
|
||||||
for _, a := range aspsps {
|
for _, a := range aspsps {
|
||||||
if a.Name == institution && a.Country == country {
|
if a.Name == institution && a.Country == country {
|
||||||
validity = a.MaximumConsentValidity
|
validity = a.MaximumConsentValidity
|
||||||
|
supported = a.psuTypes()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if validity <= 0 {
|
if validity <= 0 {
|
||||||
return "", fmt.Errorf("institution is unavailable for personal account information or has no valid consent duration")
|
return "", &ConsentError{institution + " does not offer account information through Enable Banking, or advertises no valid consent duration"}
|
||||||
|
}
|
||||||
|
if !slices.Contains(supported, psuType) {
|
||||||
|
return "", &ConsentError{fmt.Sprintf("%s does not offer account information for %s accounts; it supports: %s", institution, psuType, strings.Join(supported, ", "))}
|
||||||
}
|
}
|
||||||
// Avoid overflow or unexpectedly long access while honoring each bank's limit.
|
// Avoid overflow or unexpectedly long access while honoring each bank's limit.
|
||||||
if validity > 180*24*3600 {
|
if validity > 180*24*3600 {
|
||||||
@@ -553,7 +603,7 @@ func (p *EnableBanking) Authorize(ctx context.Context, institution, country, sta
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
RedirectURL string `json:"redirect_url"`
|
RedirectURL string `json:"redirect_url"`
|
||||||
PSUType string `json:"psu_type"`
|
PSUType string `json:"psu_type"`
|
||||||
}{ASPSP: institutionDTO{institution, country}, State: state, RedirectURL: p.redirectURL, PSUType: "personal"}
|
}{ASPSP: institutionDTO{institution, country}, State: state, RedirectURL: p.redirectURL, PSUType: psuType}
|
||||||
request.Access.ValidUntil = time.Now().UTC().Add(time.Duration(validity) * time.Second).Format(time.RFC3339)
|
request.Access.ValidUntil = time.Now().UTC().Add(time.Duration(validity) * time.Second).Format(time.RFC3339)
|
||||||
request.Access.Balances = true
|
request.Access.Balances = true
|
||||||
request.Access.Transactions = true
|
request.Access.Transactions = true
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/aspsps":
|
case "/aspsps":
|
||||||
if r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" {
|
if r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "" || r.URL.Query().Get("service") != "AIS" {
|
||||||
t.Error("institution filter missing")
|
t.Errorf("wrong institution listing request: %s", r.URL)
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
|
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}]}`)
|
||||||
case "/auth":
|
case "/auth":
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
t.Error("wrong auth method")
|
t.Error("wrong auth method")
|
||||||
@@ -172,7 +172,7 @@ func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
p, k := testProvider(t, handler)
|
p, k := testProvider(t, handler)
|
||||||
key = k
|
key = k
|
||||||
authorization, err := p.Authorize(context.Background(), "N26", "de", "csrf-state")
|
authorization, err := p.Authorize(context.Background(), "N26", "de", PSUPersonal, "csrf-state")
|
||||||
if err != nil || authorization != "https://enablebanking.com/auth/consent" {
|
if err != nil || authorization != "https://enablebanking.com/auth/consent" {
|
||||||
t.Fatalf("authorize: %s %v", authorization, err)
|
t.Fatalf("authorize: %s %v", authorization, err)
|
||||||
}
|
}
|
||||||
@@ -253,17 +253,19 @@ func TestEnableBankingLongestStrategyIsOnlySentWhenRequested(t *testing.T) {
|
|||||||
}
|
}
|
||||||
func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *testing.T) {
|
func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *testing.T) {
|
||||||
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/aspsps" || r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" || r.URL.Query().Get("service") != "AIS" {
|
if r.URL.Path != "/aspsps" || r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("service") != "AIS" {
|
||||||
t.Errorf("wrong institution listing request: %s", r.URL)
|
t.Errorf("wrong institution listing request: %s", r.URL)
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, `{"aspsps":[
|
fmt.Fprint(w, `{"aspsps":[
|
||||||
{"name":"Sparkasse","country":"DE","maximum_consent_validity":3600,"logo":"https://enablebanking.com/brands/DE/Sparkasse/"},
|
{"name":"Sparkasse","country":"DE","psu_types":["personal","business"],"maximum_consent_validity":3600,"logo":"https://enablebanking.com/brands/DE/Sparkasse/"},
|
||||||
{"name":"N26","country":"DE","maximum_consent_validity":3600,"logo":"http://enablebanking.com/brands/DE/N26/"},
|
{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"http://enablebanking.com/brands/DE/N26/"},
|
||||||
{"name":"Tracker Bank","country":"DE","maximum_consent_validity":3600,"logo":"https://tracker.example/pixel.png"},
|
{"name":"Kontist","country":"DE","psu_types":["business"],"maximum_consent_validity":3600},
|
||||||
{"name":"Evil","country":"DE","maximum_consent_validity":3600,"logo":"https://evil-enablebanking.com/logo"},
|
{"name":"Tracker Bank","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"https://tracker.example/pixel.png"},
|
||||||
{"name":"Dormant","country":"DE","maximum_consent_validity":0},
|
{"name":"Evil","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"https://evil-enablebanking.com/logo"},
|
||||||
{"name":"Elsewhere","country":"AT","maximum_consent_validity":3600},
|
{"name":"Dormant","country":"DE","psu_types":["personal"],"maximum_consent_validity":0},
|
||||||
{"name":"","country":"DE","maximum_consent_validity":3600}
|
{"name":"Typeless","country":"DE","psu_types":["corporate"],"maximum_consent_validity":3600},
|
||||||
|
{"name":"Elsewhere","country":"AT","psu_types":["personal"],"maximum_consent_validity":3600},
|
||||||
|
{"name":"","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}
|
||||||
]}`)
|
]}`)
|
||||||
})
|
})
|
||||||
list, err := p.Institutions(context.Background(), "de")
|
list, err := p.Institutions(context.Background(), "de")
|
||||||
@@ -271,10 +273,11 @@ func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *test
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
expected := []Institution{
|
expected := []Institution{
|
||||||
{Name: "Evil", Country: "DE"},
|
{Name: "Evil", Country: "DE", PSUTypes: []string{PSUPersonal}},
|
||||||
{Name: "N26", Country: "DE"},
|
{Name: "Kontist", Country: "DE", PSUTypes: []string{PSUBusiness}},
|
||||||
{Name: "Sparkasse", Country: "DE", Logo: "https://enablebanking.com/brands/DE/Sparkasse/"},
|
{Name: "N26", Country: "DE", PSUTypes: []string{PSUPersonal}},
|
||||||
{Name: "Tracker Bank", Country: "DE"},
|
{Name: "Sparkasse", Country: "DE", Logo: "https://enablebanking.com/brands/DE/Sparkasse/", PSUTypes: []string{PSUPersonal, PSUBusiness}},
|
||||||
|
{Name: "Tracker Bank", Country: "DE", PSUTypes: []string{PSUPersonal}},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(list, expected) {
|
if !reflect.DeepEqual(list, expected) {
|
||||||
t.Fatalf("wrong connectable institutions: %+v", list)
|
t.Fatalf("wrong connectable institutions: %+v", list)
|
||||||
@@ -283,6 +286,50 @@ func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *test
|
|||||||
t.Fatal("accepted an invalid country code")
|
t.Fatal("accepted an invalid country code")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable Banking documents that authorizing with the wrong psu_type yields a
|
||||||
|
// consent that shares no accounts, so the requested type must reach /auth and
|
||||||
|
// an unsupported type must fail before the user is sent to a bank.
|
||||||
|
func TestEnableBankingAuthorizesTheRequestedAccountHolderType(t *testing.T) {
|
||||||
|
var sent []string
|
||||||
|
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/aspsps" {
|
||||||
|
fmt.Fprint(w, `{"aspsps":[
|
||||||
|
{"name":"Kontist","country":"DE","psu_types":["business"],"maximum_consent_validity":3600},
|
||||||
|
{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}
|
||||||
|
]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
PSUType string `json:"psu_type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
sent = append(sent, request.PSUType)
|
||||||
|
fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`)
|
||||||
|
})
|
||||||
|
if _, err := p.Authorize(context.Background(), "Kontist", "DE", PSUBusiness, "state"); err != nil {
|
||||||
|
t.Fatalf("business consent rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.Authorize(context.Background(), "N26", "DE", PSUPersonal, "state"); err != nil {
|
||||||
|
t.Fatalf("personal consent rejected: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(sent, []string{PSUBusiness, PSUPersonal}) {
|
||||||
|
t.Fatalf("requested account types did not reach the provider: %q", sent)
|
||||||
|
}
|
||||||
|
_, err := p.Authorize(context.Background(), "Kontist", "DE", PSUPersonal, "state")
|
||||||
|
var consent *ConsentError
|
||||||
|
if !errors.As(err, &consent) || !strings.Contains(err.Error(), "business") {
|
||||||
|
t.Fatalf("personal consent for a business-only bank was not refused: %v", err)
|
||||||
|
}
|
||||||
|
if len(sent) != 2 {
|
||||||
|
t.Fatal("unsupported account type still started a bank authorization")
|
||||||
|
}
|
||||||
|
if _, err := p.Authorize(context.Background(), "N26", "DE", "corporate", "state"); err == nil {
|
||||||
|
t.Fatal("accepted an undocumented account type")
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestEnableBankingLinksUsableAccountsAndCountsTheRest(t *testing.T) {
|
func TestEnableBankingLinksUsableAccountsAndCountsTheRest(t *testing.T) {
|
||||||
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprint(w, `{"session_id":"session-1","access":{"valid_until":"2099-01-01T00:00:00Z"},"aspsp":{"name":"ING","country":"DE"},"accounts":[
|
fmt.Fprint(w, `{"session_id":"session-1","access":{"valid_until":"2099-01-01T00:00:00Z"},"aspsp":{"name":"ING","country":"DE"},"accounts":[
|
||||||
@@ -484,7 +531,10 @@ func TestEnableBankingCooldownCoversAllEndpoints(t *testing.T) {
|
|||||||
"balances": func() error { _, err := p.Balances(context.Background(), "uid"); return err },
|
"balances": func() error { _, err := p.Balances(context.Background(), "uid"); return err },
|
||||||
"transactions": func() error { _, err := p.Transactions(context.Background(), account, "", "", false); return err },
|
"transactions": func() error { _, err := p.Transactions(context.Background(), account, "", "", false); return err },
|
||||||
"exchange": func() error { _, err := p.Exchange(context.Background(), "once-only-code"); return err },
|
"exchange": func() error { _, err := p.Exchange(context.Background(), "once-only-code"); return err },
|
||||||
"authorize": func() error { _, err := p.Authorize(context.Background(), "N26", "DE", "state"); return err },
|
"authorize": func() error {
|
||||||
|
_, err := p.Authorize(context.Background(), "N26", "DE", PSUPersonal, "state")
|
||||||
|
return err
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
err := request()
|
err := request()
|
||||||
@@ -505,7 +555,7 @@ func TestEnableBankingNeverReplaysMutationAfterRateLimit(t *testing.T) {
|
|||||||
var posts atomic.Int32
|
var posts atomic.Int32
|
||||||
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/aspsps" {
|
if r.URL.Path == "/aspsps" {
|
||||||
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
|
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}]}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -519,7 +569,7 @@ func TestEnableBankingNeverReplaysMutationAfterRateLimit(t *testing.T) {
|
|||||||
if endpoint == "exchange" {
|
if endpoint == "exchange" {
|
||||||
_, err = p.Exchange(context.Background(), "once-only-code")
|
_, err = p.Exchange(context.Background(), "once-only-code")
|
||||||
} else {
|
} else {
|
||||||
_, err = p.Authorize(context.Background(), "N26", "DE", "state")
|
_, err = p.Authorize(context.Background(), "N26", "DE", PSUPersonal, "state")
|
||||||
}
|
}
|
||||||
var limit *ratelimit.RateLimitError
|
var limit *ratelimit.RateLimitError
|
||||||
if !errors.As(err, &limit) || strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) {
|
if !errors.As(err, &limit) || strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) {
|
||||||
|
|||||||
@@ -403,12 +403,13 @@ 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"`
|
||||||
|
PSUType string `json:"psu_type"`
|
||||||
HistoryMonths int `json:"history_months"`
|
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, b.HistoryMonths)
|
v, e := s.app.Authorize(r.Context(), b.Institution, b.Country, b.PSUType, 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) {
|
||||||
|
|||||||
+2
-1
@@ -27,6 +27,7 @@ let
|
|||||||
root = ../web;
|
root = ../web;
|
||||||
fileset = lib.fileset.unions [
|
fileset = lib.fileset.unions [
|
||||||
../web/src
|
../web/src
|
||||||
|
../web/public
|
||||||
../web/index.html
|
../web/index.html
|
||||||
../web/package.json
|
../web/package.json
|
||||||
../web/package-lock.json
|
../web/package-lock.json
|
||||||
@@ -34,7 +35,7 @@ let
|
|||||||
../web/vite.config.ts
|
../web/vite.config.ts
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
npmDepsHash = "sha256-Sq4qmgNpg8b3fN8v1QHiISMQt5ZHI6oZ8J0M5svV0Ys=";
|
npmDepsHash = "sha256-u1pe2mJk8eyzzyZ1O55kxUhxdpHRCIyGOeuuP1RILFs=";
|
||||||
npmFlags = [ "--ignore-scripts" ];
|
npmFlags = [ "--ignore-scripts" ];
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|||||||
+45
-6
@@ -188,11 +188,13 @@ function AccountsContent({
|
|||||||
async function authorize(
|
async function authorize(
|
||||||
institution: string,
|
institution: string,
|
||||||
country: string,
|
country: string,
|
||||||
|
psuType: string,
|
||||||
historyMonths: number,
|
historyMonths: number,
|
||||||
) {
|
) {
|
||||||
const response = await request<{ url: string }>("/api/banking/authorize", {
|
const response = await request<{ url: string }>("/api/banking/authorize", {
|
||||||
institution,
|
institution,
|
||||||
country,
|
country,
|
||||||
|
psu_type: psuType,
|
||||||
history_months: historyMonths,
|
history_months: historyMonths,
|
||||||
});
|
});
|
||||||
const url = new URL(response.url);
|
const url = new URL(response.url);
|
||||||
@@ -302,6 +304,7 @@ function AccountCard({
|
|||||||
await authorize(
|
await authorize(
|
||||||
institution,
|
institution,
|
||||||
connection.country || "DE",
|
connection.country || "DE",
|
||||||
|
connection.psu_type || "personal",
|
||||||
connection.history_months,
|
connection.history_months,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -634,8 +637,19 @@ function ConnectForm({
|
|||||||
onError: (error: string) => void;
|
onError: (error: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [institution, setInstitution] = useState("");
|
const [institution, setInstitution] = useState("");
|
||||||
|
const [psuTypes, setPSUTypes] = useState<string[] | null>(null);
|
||||||
|
const [psuType, setPSUType] = useState("personal");
|
||||||
const [country, setCountry] = useState("DE");
|
const [country, setCountry] = useState("DE");
|
||||||
const [historyMonths, setHistoryMonths] = useState("12");
|
const [historyMonths, setHistoryMonths] = useState("12");
|
||||||
|
// Supported account types come from the bank listing. Authorizing a business
|
||||||
|
// account with the personal flow yields a consent that shares no accounts,
|
||||||
|
// so keep the choice inside what the selected bank actually offers.
|
||||||
|
const chooseInstitution = (name: string, supported?: string[]) => {
|
||||||
|
setInstitution(name);
|
||||||
|
setPSUTypes(supported ?? null);
|
||||||
|
if (supported?.length && !supported.includes(psuType))
|
||||||
|
setPSUType(supported[0]);
|
||||||
|
};
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const callback =
|
const callback =
|
||||||
@@ -696,7 +710,12 @@ function ConnectForm({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
onError("");
|
onError("");
|
||||||
try {
|
try {
|
||||||
await authorize(institution.trim(), country, Number(historyMonths));
|
await authorize(
|
||||||
|
institution.trim(),
|
||||||
|
country,
|
||||||
|
psuType,
|
||||||
|
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);
|
||||||
@@ -707,8 +726,28 @@ function ConnectForm({
|
|||||||
country={country}
|
country={country}
|
||||||
configured={state.status.banking_configured}
|
configured={state.status.banking_configured}
|
||||||
value={institution}
|
value={institution}
|
||||||
onChange={setInstitution}
|
onChange={chooseInstitution}
|
||||||
/>
|
/>
|
||||||
|
<Field
|
||||||
|
label="Account type"
|
||||||
|
hint={
|
||||||
|
psuTypes?.length === 1
|
||||||
|
? `${institution} offers account information for ${psuTypes[0]} accounts only.`
|
||||||
|
: "Business accounts must be authorized as business: the personal flow returns a consent without accounts."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={psuType}
|
||||||
|
onChange={(e) => setPSUType(e.target.value)}
|
||||||
|
>
|
||||||
|
{(psuTypes ?? ["personal", "business"]).map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{kind === "business" ? "Business" : "Personal"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<Field label="Country" hint="Two-letter country code">
|
<Field label="Country" hint="Two-letter country code">
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -717,7 +756,7 @@ function ConnectForm({
|
|||||||
value={country}
|
value={country}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCountry(e.target.value.toUpperCase());
|
setCountry(e.target.value.toUpperCase());
|
||||||
setInstitution("");
|
chooseInstitution("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -765,7 +804,7 @@ function InstitutionSelect({
|
|||||||
country: string;
|
country: string;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (name: string) => void;
|
onChange: (name: string, psuTypes?: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [institutions, setInstitutions] = useState<Institution[] | null>(null);
|
const [institutions, setInstitutions] = useState<Institution[] | null>(null);
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
@@ -840,7 +879,7 @@ function InstitutionSelect({
|
|||||||
if (e.key === "Enter" && open) {
|
if (e.key === "Enter" && open) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (shown.length === 1) {
|
if (shown.length === 1) {
|
||||||
onChange(shown[0].name);
|
onChange(shown[0].name, shown[0].psu_types);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,7 +899,7 @@ function InstitutionSelect({
|
|||||||
aria-selected={i.name === value}
|
aria-selected={i.name === value}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(i.name);
|
onChange(i.name, i.psu_types);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface Connection {
|
|||||||
account_id: string;
|
account_id: string;
|
||||||
institution: string;
|
institution: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
psu_type: string;
|
||||||
history_months: number;
|
history_months: number;
|
||||||
status: "local" | "connected" | "reconnect_required" | "error";
|
status: "local" | "connected" | "reconnect_required" | "error";
|
||||||
valid_until: string;
|
valid_until: string;
|
||||||
@@ -77,6 +78,7 @@ export interface Institution {
|
|||||||
name: string;
|
name: string;
|
||||||
country: string;
|
country: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
psu_types: string[];
|
||||||
}
|
}
|
||||||
export interface State {
|
export interface State {
|
||||||
data: Dataset;
|
data: Dataset;
|
||||||
|
|||||||
Reference in New Issue
Block a user