Three defects made new connections silently vanish while removed accounts returned: - A single shared account the journal cannot represent (securities or card entries without IBAN, stable identification or currency) aborted the entire consent. Usable accounts are now linked and the rest counted and reported. - A consent that linked nothing was stored, redirected as success and later reaped by session recovery. It now fails with the reason. - Callback failures rendered a bare JSON error page and were never logged. They now log and redirect into the app with the reason shown. - Deleting an account left its session binding, so the next connect or sync recovered the binding and re-added the account. Account deletion now releases bindings, consents and cursors before committing.
530 lines
17 KiB
Go
530 lines
17 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Callback completes a bank authorization and returns how many accounts the
|
|
// bank shared that could not be linked to a journal account.
|
|
func (a *App) Callback(ctx context.Context, code, state string) (int, 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 0, errors.New("authorization state expired or invalid; reconnect again")
|
|
}
|
|
if a.bank == nil || code == "" {
|
|
return 0, errors.New("authorization did not provide a code")
|
|
}
|
|
session, err := a.bank.Exchange(ctx, code)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// A consent without linkable accounts can never sync and its stored
|
|
// session would be reaped silently. Fail visibly instead.
|
|
if len(session.Accounts) == 0 {
|
|
if session.Unlinkable > 0 {
|
|
return 0, fmt.Errorf("the bank shared %d account(s), but none could be linked: they lack an IBAN or stable identification, or use an unsupported currency", session.Unlinkable)
|
|
}
|
|
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.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, HistoryMonths: auth.HistoryMonths}
|
|
if err = a.saveOps(); err != nil {
|
|
return 0, err
|
|
}
|
|
s, err := a.snapshot(ctx)
|
|
if err != nil {
|
|
return 0, 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 0, err
|
|
}
|
|
if _, err = a.commit(ctx, s.Revision, s.Data); err != nil {
|
|
return 0, err
|
|
}
|
|
select {
|
|
case a.syncRequested <- struct{}{}:
|
|
default:
|
|
}
|
|
return session.Unlinkable, nil
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|