Files
finance-duck/internal/app/import.go
T
Lars Nolden 3df9bda989 Replace free-text institution entry with a searchable bank picker
GET /api/banking/institutions lists the banks Enable Banking can connect
for a country (personal AIS, connectable consents only), with logos
restricted to https Enable Banking hosts to match the CSP image
allowlist. The connect form offers a filterable dropdown with bank
logos, falling back to the previous free-text input when the list is
unavailable or banking is not configured.
2026-09-11 11:17:03 +02:00

519 lines
16 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"io"
"slices"
"strings"
"time"
"finance-duck/internal/banking"
"finance-duck/internal/classification"
"finance-duck/internal/domain"
"finance-duck/internal/ratelimit"
)
type ImportResult struct {
Imported int `json:"imported"`
// RequestedFrom and EarliestFetched describe manual history retrieval:
// the requested window start, and the oldest booking date the bank
// actually returned (empty when it returned nothing).
RequestedFrom string `json:"requested_from,omitempty"`
EarliestFetched string `json:"earliest_fetched,omitempty"`
State State `json:"state"`
}
func addProposal(d *domain.Dataset, p classification.Proposal) error {
if p.NewMerchant != nil {
m := *p.NewMerchant
if slices.ContainsFunc(d.Merchants, func(v domain.Merchant) bool { return v.ID == m.ID }) {
return errors.New("proposed merchant ID already exists")
}
d.Merchants = append(d.Merchants, m)
}
return nil
}
func (a *App) importFacts(ctx context.Context, s State, facts []domain.Facts) (ImportResult, error) {
added, err := banking.NormalizeAndDedupe(s.Data, facts)
if err != nil {
return ImportResult{}, err
}
if len(added) == 0 {
return ImportResult{State: s}, nil
}
s.Data.Transactions = append(s.Data.Transactions, added...)
banking.MatchTransfers(&s.Data)
// Commit imported facts before calling any model: remote failures cannot lose money records.
s, err = a.commit(ctx, s.Revision, s.Data)
if err != nil {
return ImportResult{}, err
}
ids := make(map[string]bool, len(added))
for _, t := range added {
ids[t.Facts.ID] = true
}
for i, t := range s.Data.Transactions {
if !ids[t.Facts.ID] || t.Enrichment.Kind == "transfer" {
continue
}
p, e := a.classifier.Classify(ctx, t.Facts, s.Data, false)
if e == nil {
e = addProposal(&s.Data, p)
}
if e == nil {
e = domain.ValidateEnrichment(s.Data, t.Facts, p.Enrichment)
}
if e != nil {
s.Data.Transactions[i].Enrichment.Classification = domain.Provenance{Source: "unclassified", Timestamp: time.Now().UTC().Format(time.RFC3339), Error: e.Error()}
continue
}
s.Data.Transactions[i].Enrichment = p.Enrichment
}
state, err := a.commit(ctx, s.Revision, s.Data)
if err != nil {
return ImportResult{}, fmt.Errorf("facts imported; enrichment commit failed: %w", err)
}
return ImportResult{Imported: len(added), State: state}, nil
}
func (a *App) ImportCSV(ctx context.Context, rev, accountID string, r io.Reader) (ImportResult, error) {
a.mu.Lock()
defer a.mu.Unlock()
s, err := a.snapshot(ctx)
if err != nil {
return ImportResult{}, err
}
if rev != s.Revision {
return ImportResult{}, errors.New("revision conflict: reload before importing")
}
for _, account := range s.Data.Accounts {
if account.ID == accountID {
facts, e := banking.ParseCSV(r, account)
if e != nil {
return ImportResult{}, e
}
return a.importFacts(ctx, s, facts)
}
}
return ImportResult{}, errors.New("unknown account")
}
func (a *App) Backfill(ctx context.Context, rev, accountID string, historyMonths int) (ImportResult, error) {
a.mu.Lock()
defer a.mu.Unlock()
if historyMonths < 1 || historyMonths > 120 {
return ImportResult{}, errors.New("history_months must be an integer between 1 and 120")
}
s, err := a.snapshot(ctx)
if err != nil {
return ImportResult{}, err
}
if rev != s.Revision {
return ImportResult{}, errors.New("revision conflict: reload before importing")
}
if a.bank == nil {
return ImportResult{}, errors.New("Enable Banking is not configured")
}
index := slices.IndexFunc(s.Data.Accounts, func(account domain.Account) bool { return account.ID == accountID })
if index < 0 {
return ImportResult{}, errors.New("unknown account")
}
account := s.Data.Accounts[index]
if account.ExternalAccountID == "" {
return ImportResult{}, errors.New("account is not connected")
}
var session *banking.Session
for i := len(a.ops.Sessions) - 1; i >= 0; i-- {
saved := &a.ops.Sessions[i]
if slices.ContainsFunc(saved.Accounts, func(linked domain.Account) bool {
return linked.ID == account.ID && linked.ExternalAccountID == account.ExternalAccountID
}) {
session = saved
break
}
}
if session == nil || session.ID == "" {
return ImportResult{}, errors.New("account is not connected")
}
expiry, err := time.Parse(time.RFC3339, session.ValidUntil)
if a.ops.Consents[session.ID].NeedsReconnect || err != nil || !expiry.After(time.Now()) {
return ImportResult{}, banking.ErrReconnect
}
current, err := a.bank.Status(ctx, session.ID)
if err != nil {
return ImportResult{}, bankFailure(err, "bank connection unavailable; retry importing history")
}
expiry, err = time.Parse(time.RFC3339, current.ValidUntil)
if err != nil || !expiry.After(time.Now()) {
return ImportResult{}, banking.ErrReconnect
}
if !slices.Contains(current.AccountIDs, account.ExternalAccountID) {
return ImportResult{}, banking.ErrReconnect
}
now := time.Now().UTC()
from := now.AddDate(0, -historyMonths, 0).Format("2006-01-02")
// The longest fetching strategy imports whatever period the bank still
// permits: many banks cap history on an established consent instead of
// serving the full requested range.
facts, err := a.bank.Transactions(ctx, account, from, now.Format("2006-01-02"), true)
if err != nil {
return ImportResult{}, bankFailure(err, "transaction retrieval failed; retry importing history")
}
// Use normal import processing without changing sync cursors or saved consent
// settings, including when the requested range adds no transactions.
result, err := a.importFacts(ctx, s, facts)
if err != nil {
return ImportResult{}, err
}
result.RequestedFrom = from
for _, f := range facts {
if result.EarliestFetched == "" || f.BookingDate < result.EarliestFetched {
result.EarliestFetched = f.BookingDate
}
}
return result, nil
}
func (a *App) Authorize(ctx context.Context, institution, country string, historyMonths int) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if historyMonths < 1 || historyMonths > 120 {
return "", errors.New("history_months must be an integer between 1 and 120")
}
if a.bank == nil {
return "", errors.New("Enable Banking is not configured")
}
institution = strings.TrimSpace(institution)
country = strings.ToUpper(strings.TrimSpace(country))
if institution == "" {
return "", errors.New("institution is required")
}
if len(country) != 2 {
return "", errors.New("country must be a two-letter code")
}
for state, auth := range a.authStates {
if time.Now().After(auth.Expires) {
delete(a.authStates, state)
}
}
state := domain.NewID("auth")
url, err := a.bank.Authorize(ctx, institution, country, state)
if err == nil {
a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, HistoryMonths: historyMonths}
}
return url, err
}
// Institutions lists connectable banks for the country so the UI can offer
// a picker instead of free-text entry. Provider failures stay sanitized.
func (a *App) Institutions(ctx context.Context, country string) ([]banking.Institution, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.bank == nil {
return nil, errors.New("Enable Banking is not configured")
}
list, err := a.bank.Institutions(ctx, country)
if err != nil {
return nil, bankFailure(err, "institution list unavailable; retry")
}
return list, nil
}
func normalizedIBAN(s string) string { return strings.ToUpper(strings.Join(strings.Fields(s), "")) }
func connectAccounts(d *domain.Dataset, session *banking.Session, reconnect bool) {
for i, account := range session.Accounts {
found := -1
for j, local := range d.Accounts {
if local.ID == account.ID || (account.ExternalAccountID != "" && local.ExternalAccountID == account.ExternalAccountID) || (account.IBAN != "" && normalizedIBAN(account.IBAN) == normalizedIBAN(local.IBAN)) {
found = j
break
}
}
if found >= 0 {
local := d.Accounts[found]
local.ExternalAccountID = account.ExternalAccountID
if account.IBAN != "" {
local.IBAN = account.IBAN
}
if reconnect {
local.Active = true
}
session.Accounts[i] = local
d.Accounts[found] = local
} else {
if account.ID == "" {
account.ID = domain.NewID("acct")
}
account.Active = true
session.Accounts[i] = account
d.Accounts = append(d.Accounts, account)
}
}
}
func (a *App) Callback(ctx context.Context, code, state string) error {
a.mu.Lock()
defer a.mu.Unlock()
auth, ok := a.authStates[state]
delete(a.authStates, state)
if !ok || time.Now().After(auth.Expires) {
return errors.New("authorization state expired or invalid; reconnect again")
}
if a.bank == nil || code == "" {
return errors.New("authorization did not provide a code")
}
session, err := a.bank.Exchange(ctx, code)
if err != nil {
return err
}
a.ops.Sessions = append(a.ops.Sessions, session)
a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, HistoryMonths: auth.HistoryMonths}
if err = a.saveOps(); err != nil {
return err
}
s, err := a.snapshot(ctx)
if err != nil {
return err
}
connectAccounts(&s.Data, &session, true)
// Remove superseded account bindings, not unrelated bank consents.
replacements := map[string]bool{}
for _, account := range session.Accounts {
replacements[account.ID] = true
}
sessions := make([]banking.Session, 0, len(a.ops.Sessions)+1)
for _, old := range a.ops.Sessions {
if old.ID == session.ID {
continue
}
old.Accounts = slices.DeleteFunc(slices.Clone(old.Accounts), func(account domain.Account) bool { return replacements[account.ID] })
if len(old.Accounts) > 0 {
sessions = append(sessions, old)
} else {
delete(a.ops.Consents, old.ID)
}
}
a.ops.Sessions = append(sessions, session)
// Save once-only provider details before the canonical commit. Sync can recover
// the account bindings if a crash or external edit interrupts that commit.
if err = a.saveOps(); err != nil {
return err
}
_, err = a.commit(ctx, s.Revision, s.Data)
if err == nil {
select {
case a.syncRequested <- struct{}{}:
default:
}
}
return err
}
func (a *App) Balances(ctx context.Context, id string) ([]banking.Balance, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.bank == nil {
return nil, errors.New("Enable Banking is not configured")
}
s, err := a.snapshot(ctx)
if err != nil {
return nil, err
}
for _, account := range s.Data.Accounts {
if account.ID == id && account.ExternalAccountID != "" {
for _, session := range a.ops.Sessions {
for _, linked := range session.Accounts {
if linked.ID == account.ID && linked.ExternalAccountID == account.ExternalAccountID {
return a.bank.Balances(ctx, linked.ExternalAccountID)
}
}
}
}
}
return nil, errors.New("account is not connected")
}
// Only typed, locally generated errors are safe to expose; provider errors may
// wrap private response data even when their underlying cause is recognizable.
func bankFailure(err error, fallback string) error {
var background *banking.BackgroundQuotaError
if errors.As(err, &background) {
return fmt.Errorf("Enable Banking: %w", background)
}
var limited *ratelimit.RateLimitError
if errors.As(err, &limited) {
return fmt.Errorf("Enable Banking: %w", limited)
}
if errors.Is(err, banking.ErrReconnect) {
return banking.ErrReconnect
}
var api *banking.APIError
if errors.As(err, &api) {
return api
}
return errors.New(fallback)
}
func (a *App) Sync(ctx context.Context) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.bank == nil {
return State{}, errors.New("Enable Banking is not configured")
}
s, err := a.snapshot(ctx)
if err != nil {
return State{}, err
}
var failures []string
for i := range a.ops.Sessions {
connectAccounts(&s.Data, &a.ops.Sessions[i], false)
}
// Recovery may have both the old consent and its once-only replacement.
// Keep the newest binding for each local account before checking bank status.
claimed := map[string]bool{}
retained := make([]banking.Session, 0, len(a.ops.Sessions))
for i := len(a.ops.Sessions) - 1; i >= 0; i-- {
session := a.ops.Sessions[i]
session.Accounts = slices.DeleteFunc(slices.Clone(session.Accounts), func(account domain.Account) bool {
if claimed[account.ID] {
return true
}
claimed[account.ID] = true
return false
})
if len(session.Accounts) == 0 {
delete(a.ops.Consents, session.ID)
} else {
retained = append(retained, session)
}
}
slices.Reverse(retained)
a.ops.Sessions = retained
s, err = a.commit(ctx, s.Revision, s.Data)
if err != nil {
return State{}, err
}
validAccounts := map[string]bool{}
accountSession := map[string]string{}
failedSessions := map[string]bool{}
for i, session := range a.ops.Sessions {
for _, account := range session.Accounts {
accountSession[account.ID] = session.ID
}
meta := a.ops.Consents[session.ID]
current, e := a.bank.Status(ctx, session.ID)
if e == nil {
expiry, parseErr := time.Parse(time.RFC3339, current.ValidUntil)
if parseErr != nil || !expiry.After(time.Now()) {
e = banking.ErrReconnect
}
}
if e != nil {
meta.Error = bankFailure(e, "bank connection unavailable; retry synchronization").Error()
meta.NeedsReconnect = errors.Is(e, banking.ErrReconnect)
a.ops.Consents[session.ID] = meta
failures = append(failures, meta.Institution+": "+meta.Error)
failedSessions[session.ID] = true
continue
}
meta.Error = ""
meta.NeedsReconnect = false
a.ops.Consents[session.ID] = meta
a.ops.Sessions[i].ValidUntil = current.ValidUntil
for _, account := range session.Accounts {
if account.ExternalAccountID != "" && slices.Contains(current.AccountIDs, account.ExternalAccountID) {
validAccounts[account.ID] = true
}
}
}
now := time.Now().UTC()
to := now.Format("2006-01-02")
for _, account := range s.Data.Accounts {
if !account.Active || account.ExternalAccountID == "" {
continue
}
sessionID := accountSession[account.ID]
if failedSessions[sessionID] {
continue
}
if !validAccounts[account.ID] {
failures = append(failures, account.DisplayName+": bank connection unavailable")
if sessionID != "" {
meta := a.ops.Consents[sessionID]
meta.Error = banking.ErrReconnect.Error()
meta.NeedsReconnect = true
a.ops.Consents[sessionID] = meta
}
continue
}
var from string
if last, e := time.Parse(time.RFC3339, a.ops.AccountSync[account.ID]); e == nil {
from = last.AddDate(0, 0, -14).Format("2006-01-02")
} else {
months := a.ops.Consents[accountSession[account.ID]].historyMonths()
from = now.AddDate(0, -months, 0).Format("2006-01-02")
}
facts, e := a.bank.Transactions(ctx, account, from, to, false)
if e != nil {
meta := a.ops.Consents[sessionID]
meta.Error = bankFailure(e, "transaction retrieval failed; retry synchronization").Error()
meta.NeedsReconnect = meta.NeedsReconnect || errors.Is(e, banking.ErrReconnect)
a.ops.Consents[sessionID] = meta
failures = append(failures, account.DisplayName+": "+meta.Error)
continue
}
result, e := a.importFacts(ctx, s, facts)
if e != nil {
failures = append(failures, account.DisplayName+": "+e.Error())
s, err = a.snapshot(ctx)
if err != nil {
return State{}, err
}
continue
}
s = result.State
a.ops.AccountSync[account.ID] = now.Format(time.RFC3339)
}
a.ops.SyncError = strings.Join(failures, "; ")
if len(failures) == 0 {
a.ops.LastSync = now.Format(time.RFC3339)
}
if err = a.saveOps(); err != nil {
return State{}, err
}
return a.snapshot(ctx)
}
func (a *App) RunScheduler(ctx context.Context) {
timer := time.NewTimer(time.Minute)
defer timer.Stop()
for {
force := false
select {
case <-ctx.Done():
return
case <-a.syncRequested:
force = true
case <-timer.C:
}
a.mu.Lock()
configured := a.bank != nil
last, err := time.Parse(time.RFC3339, a.ops.LastSync)
failed := a.ops.SyncError != ""
a.mu.Unlock()
due := force || err != nil || failed || time.Since(last) >= 24*time.Hour
if !configured || !due {
timer.Reset(time.Minute)
continue
}
a.Sync(ctx)
a.mu.Lock()
failed = a.ops.SyncError != ""
a.mu.Unlock()
if failed {
// A failed sync leaves its persisted error banner behind. Retry
// hourly so transient provider failures clear without waiting a
// day, while bounding unattended traffic toward the provider.
timer.Reset(time.Hour)
} else {
timer.Reset(24 * time.Hour)
}
}
}