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) } } }