init
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"finance-duck/internal/analytics"
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/classification"
|
||||
"finance-duck/internal/domain"
|
||||
"finance-duck/internal/journal"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Model string `json:"model"`
|
||||
IncludeAmount bool `json:"include_amount"`
|
||||
}
|
||||
type Status struct {
|
||||
SyncError string `json:"sync_error"`
|
||||
IndexError string `json:"index_error"`
|
||||
LastSync string `json:"last_sync"`
|
||||
BankingConfigured bool `json:"banking_configured"`
|
||||
AIConfigured bool `json:"ai_configured"`
|
||||
}
|
||||
type State struct {
|
||||
Data domain.Dataset `json:"data"`
|
||||
Revision string `json:"revision"`
|
||||
Status Status `json:"status"`
|
||||
Settings Settings `json:"settings"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
Connections []Connection `json:"connections"`
|
||||
}
|
||||
type operational struct {
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
LastSync string `json:"last_sync"`
|
||||
SyncError string `json:"sync_error"`
|
||||
Consents map[string]Consent `json:"consents"`
|
||||
AccountSync map[string]string `json:"account_sync"`
|
||||
}
|
||||
type App struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
journal *journal.Store
|
||||
index *analytics.Store
|
||||
indexed string
|
||||
indexError string
|
||||
settings Settings
|
||||
ops operational
|
||||
bank banking.Provider
|
||||
classifier classification.Client
|
||||
previews map[string]Preview
|
||||
authStates map[string]authorization
|
||||
callbackURL string
|
||||
syncRequested chan struct{}
|
||||
}
|
||||
|
||||
func Open(dir string) (*App, error) {
|
||||
j, err := journal.Open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &App{dir: dir, journal: j, previews: make(map[string]Preview), authStates: make(map[string]authorization), syncRequested: make(chan struct{}, 1)}
|
||||
fail := func(e error) (*App, error) { j.Close(); return nil, e }
|
||||
if err = os.MkdirAll(filepath.Join(dir, "state"), 0700); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Join(dir, "cache"), 0700); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if b, e := os.ReadFile(filepath.Join(dir, "config.toml")); e == nil {
|
||||
for n, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("config.toml:%d: expected key = value", n+1))
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
switch k {
|
||||
case "classification_model":
|
||||
a.settings.Model, err = strconv.Unquote(v)
|
||||
case "include_amount":
|
||||
a.settings.IncludeAmount, err = strconv.ParseBool(v)
|
||||
default:
|
||||
err = fmt.Errorf("unknown setting %q", k)
|
||||
}
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("config.toml:%d: %w", n+1, err))
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(e) {
|
||||
return fail(e)
|
||||
}
|
||||
if b, e := os.ReadFile(filepath.Join(dir, "state", "sync-state.json")); e == nil {
|
||||
if err = json.Unmarshal(b, &a.ops); err != nil {
|
||||
return fail(fmt.Errorf("sync state: %w", err))
|
||||
}
|
||||
} else if !os.IsNotExist(e) {
|
||||
return fail(e)
|
||||
}
|
||||
if a.ops.Consents == nil {
|
||||
a.ops.Consents = make(map[string]Consent)
|
||||
}
|
||||
if a.ops.AccountSync == nil {
|
||||
a.ops.AccountSync = make(map[string]string)
|
||||
}
|
||||
a.classifier = classification.Client{APIKey: os.Getenv("OPENROUTER_API_KEY"), Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
||||
appID, key, redirect := os.Getenv("ENABLEBANKING_APP_ID"), os.Getenv("ENABLEBANKING_KEY_FILE"), os.Getenv("ENABLEBANKING_REDIRECT_URL")
|
||||
a.callbackURL = redirect
|
||||
if appID != "" || key != "" || redirect != "" {
|
||||
if appID == "" || key == "" || redirect == "" {
|
||||
return fail(errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables"))
|
||||
}
|
||||
a.bank, err = banking.NewEnableBanking(appID, key, redirect)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb"))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, err = a.snapshot(context.Background()); err != nil {
|
||||
a.index.Close()
|
||||
return fail(err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func (a *App) Close() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return errors.Join(a.index.Close(), a.journal.Close())
|
||||
}
|
||||
func (a *App) snapshot(ctx context.Context) (State, error) {
|
||||
d, rev, err := a.journal.Load()
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if rev != a.indexed {
|
||||
if err = a.index.Rebuild(ctx, d); err != nil {
|
||||
a.indexError = err.Error()
|
||||
} else {
|
||||
a.indexed = rev
|
||||
a.indexError = ""
|
||||
}
|
||||
}
|
||||
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
||||
}
|
||||
func (a *App) Snapshot(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
func (a *App) commit(ctx context.Context, rev string, d domain.Dataset) (State, error) {
|
||||
if rev == "" {
|
||||
return State{}, errors.New("revision is required")
|
||||
}
|
||||
if _, err := a.journal.Commit(rev, d); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
func (a *App) Mutate(ctx context.Context, rev string, fn func(*domain.Dataset) error) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
s, err := a.snapshot(ctx)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if rev != s.Revision {
|
||||
return State{}, errors.New("revision conflict: reload before editing")
|
||||
}
|
||||
if err = fn(&s.Data); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return a.commit(ctx, rev, s.Data)
|
||||
}
|
||||
func (a *App) Dashboard(ctx context.Context, f analytics.Filter) (analytics.Dashboard, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if _, err := a.snapshot(ctx); err != nil {
|
||||
return analytics.Dashboard{}, err
|
||||
}
|
||||
if a.indexError != "" {
|
||||
return analytics.Dashboard{}, errors.New(a.indexError)
|
||||
}
|
||||
return a.index.Query(ctx, f)
|
||||
}
|
||||
func (a *App) Rebuild(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.indexed = ""
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
func atomicFile(path string, b []byte) error {
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".state-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if err = f.Chmod(0600); err == nil {
|
||||
_, err = f.Write(b)
|
||||
}
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(name, path); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Sync()
|
||||
}
|
||||
func (a *App) saveOps() error {
|
||||
b, err := json.MarshalIndent(a.ops, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n'))
|
||||
}
|
||||
func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
s.Model = strings.TrimSpace(s.Model)
|
||||
if len(s.Model) > 200 {
|
||||
return State{}, errors.New("model name is too long")
|
||||
}
|
||||
b := []byte("# Secrets belong in environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
||||
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
a.settings = s
|
||||
a.classifier.Model = s.Model
|
||||
a.classifier.IncludeAmount = s.IncludeAmount
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"finance-duck/internal/analytics"
|
||||
"finance-duck/internal/classification"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
func testApp(t *testing.T) (*App, State) {
|
||||
t.Helper()
|
||||
t.Setenv("OPENROUTER_API_KEY", "")
|
||||
t.Setenv("ENABLEBANKING_APP_ID", "")
|
||||
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
||||
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
||||
a, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.Close() })
|
||||
s, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
|
||||
d.Accounts = append(d.Accounts, domain.Account{ID: "n26", DisplayName: "N26", Currency: "EUR", Active: true})
|
||||
d.Categories = append(d.Categories, domain.Category{ID: "groceries", Name: "Groceries", ParentID: "cat_expenses", Kind: "expense"})
|
||||
d.Tags = append(d.Tags, domain.Tag{ID: "home", Name: "home"})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a, s
|
||||
}
|
||||
func sampleFacts(description, date string, amount domain.Money) domain.Facts {
|
||||
return domain.Facts{Source: "test", AccountID: "n26", BookingDate: date, Amount: amount, Currency: "EUR", RawDescription: description, ExternalID: hex.EncodeToString([]byte(description))}
|
||||
}
|
||||
func seed(t *testing.T, a *App, s State) State {
|
||||
t.Helper()
|
||||
a.mu.Lock()
|
||||
result, err := a.importFacts(context.Background(), s, []domain.Facts{sampleFacts("REWE", "2026-09-08", "-42.80"), sampleFacts("EDEKA", "2026-09-09", "-19.30")})
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.State
|
||||
}
|
||||
func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))
|
||||
defer mock.Close()
|
||||
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: mock.URL}
|
||||
s = seed(t, a, s)
|
||||
if len(s.Data.Transactions) != 2 {
|
||||
t.Fatalf("lost imported transactions: %d", len(s.Data.Transactions))
|
||||
}
|
||||
for _, tx := range s.Data.Transactions {
|
||||
if tx.Enrichment.CategoryID != domain.ExpenseFallback || tx.Enrichment.Classification.Error == "" {
|
||||
t.Fatalf("missing fallback error: %+v", tx.Enrichment)
|
||||
}
|
||||
}
|
||||
before := domain.Clone(s.Data)
|
||||
a.mu.Lock()
|
||||
again, err := a.importFacts(context.Background(), s, []domain.Facts{sampleFacts("REWE", "2026-09-08", "-42.80"), sampleFacts("EDEKA", "2026-09-09", "-19.30")})
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Imported != 0 || !reflect.DeepEqual(before, again.State.Data) {
|
||||
t.Fatal("retry changed the canonical financial dataset")
|
||||
}
|
||||
dash, err := a.Dashboard(context.Background(), analytics.Filter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dash.Totals) != 1 || dash.Totals[0].Expenses != "62.1000" {
|
||||
t.Fatalf("import not visible in analytics: %+v", dash.Totals)
|
||||
}
|
||||
}
|
||||
func mockClassifier(t *testing.T, a *App) {
|
||||
t.Helper()
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Error(err)
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
var prompt struct {
|
||||
Categories []struct{ ID, Name string } `json:"categories"`
|
||||
}
|
||||
if len(req.Messages) != 2 || json.Unmarshal([]byte(req.Messages[1].Content), &prompt) != nil {
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
category := ""
|
||||
for _, c := range prompt.Categories {
|
||||
if strings.Contains(strings.ToLower(c.Name), "groceries") {
|
||||
category = c.ID
|
||||
}
|
||||
}
|
||||
content, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": "REWE", "category_id": category, "tag_ids": []string{}})
|
||||
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"finish_reason": "stop", "message": map[string]any{"content": string(content)}}}})
|
||||
}))
|
||||
t.Cleanup(mock.Close)
|
||||
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: mock.URL}
|
||||
}
|
||||
func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
|
||||
for i := range d.Transactions {
|
||||
d.Transactions[i].Enrichment.TagIDs = []string{"home"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mockClassifier(t, a)
|
||||
before := domain.Clone(s.Data)
|
||||
preview, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(preview.Changes) != 2 || len(preview.Errors) != 0 {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
untouched, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, untouched.Data) {
|
||||
t.Fatal("preview mutated canonical records")
|
||||
}
|
||||
id := preview.Changes[0].ID
|
||||
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(applied.Data.Merchants) != len(before.Merchants) {
|
||||
t.Fatal("category-only reclassification created merchants")
|
||||
}
|
||||
for i, tx := range applied.Data.Transactions {
|
||||
if !reflect.DeepEqual(tx.Facts, before.Transactions[i].Facts) {
|
||||
t.Fatal("financial facts changed")
|
||||
}
|
||||
if !reflect.DeepEqual(tx.Enrichment.TagIDs, before.Transactions[i].Enrichment.TagIDs) {
|
||||
t.Fatal("unselected tags changed")
|
||||
}
|
||||
if tx.Facts.ID == id {
|
||||
if tx.Enrichment.CategoryID != "groceries" || tx.Enrichment.Classification.Model != "improved/model" {
|
||||
t.Fatal("selected category did not change")
|
||||
}
|
||||
} else if !reflect.DeepEqual(tx.Enrichment, before.Transactions[i].Enrichment) {
|
||||
t.Fatal("unselected transaction changed")
|
||||
}
|
||||
}
|
||||
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}); err == nil {
|
||||
t.Fatal("consumed preview applied twice")
|
||||
}
|
||||
}
|
||||
func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
mockClassifier(t, a)
|
||||
p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p.Changes) != 2 {
|
||||
t.Fatalf("expected category changes before stale apply: %+v", p)
|
||||
}
|
||||
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { d.Transactions[0].Enrichment.TagIDs = []string{"home"}; return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
|
||||
t.Fatal("stale preview overwrote manual edit")
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(s.Data, after.Data) {
|
||||
t.Fatal("stale apply partially changed records")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
type authorization struct {
|
||||
Expires time.Time
|
||||
Institution string
|
||||
Country string
|
||||
}
|
||||
type Consent struct {
|
||||
Institution string `json:"institution"`
|
||||
Country string `json:"country"`
|
||||
Error string `json:"error,omitempty"`
|
||||
NeedsReconnect bool `json:"needs_reconnect"`
|
||||
}
|
||||
type Connection struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Institution string `json:"institution"`
|
||||
Country string `json:"country"`
|
||||
Status string `json:"status"`
|
||||
ValidUntil string `json:"valid_until"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (a *App) connections(d domain.Dataset) []Connection {
|
||||
out := make([]Connection, 0, len(d.Accounts))
|
||||
for _, account := range d.Accounts {
|
||||
c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", Status: "local"}
|
||||
if account.ExternalAccountID != "" {
|
||||
c.Status = "reconnect_required"
|
||||
c.Error = "No saved bank consent; reconnect this account"
|
||||
}
|
||||
for _, session := range a.ops.Sessions {
|
||||
for _, linked := range session.Accounts {
|
||||
if linked.ID != account.ID {
|
||||
continue
|
||||
}
|
||||
meta := a.ops.Consents[session.ID]
|
||||
if meta.Institution != "" {
|
||||
c.Institution = meta.Institution
|
||||
}
|
||||
if meta.Country != "" {
|
||||
c.Country = meta.Country
|
||||
}
|
||||
c.ValidUntil = session.ValidUntil
|
||||
c.Error = meta.Error
|
||||
c.Status = "connected"
|
||||
expiry, err := time.Parse(time.RFC3339, session.ValidUntil)
|
||||
if meta.NeedsReconnect || err != nil || !expiry.After(time.Now()) {
|
||||
c.Status = "reconnect_required"
|
||||
if c.Error == "" {
|
||||
c.Error = "Bank consent expired; reconnect to resume automatic imports"
|
||||
}
|
||||
} else if meta.Error != "" {
|
||||
c.Status = "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copySessions(sessions []banking.Session) []banking.Session {
|
||||
out := append([]banking.Session{}, sessions...)
|
||||
for i := range out {
|
||||
out[i].Accounts = slices.Clone(out[i].Accounts)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
type historyBank struct {
|
||||
bankScenario
|
||||
fetched chan struct{}
|
||||
}
|
||||
|
||||
func (b *historyBank) Transactions(_ context.Context, account domain.Account, from, to string) ([]domain.Facts, error) {
|
||||
var rows []domain.Facts
|
||||
for _, days := range []int{60, 1} {
|
||||
date := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
if date >= from && date <= to {
|
||||
rows = append(rows, domain.Facts{Source: "enablebanking", AccountID: account.ID, BookingDate: date, Amount: "-10.00", Currency: "EUR", RawDescription: "Card payment", ExternalID: "entry_" + date})
|
||||
}
|
||||
}
|
||||
if b.fetched != nil {
|
||||
select {
|
||||
case b.fetched <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
func TestNewAccountImportsHistoryIndependentOfExistingSyncCursor(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
old := s.Data.Accounts[0]
|
||||
old.ExternalAccountID = "old_uid"
|
||||
fresh := domain.Account{ID: "ing", DisplayName: "ING", Institution: "ING", Currency: "EUR", ExternalAccountID: "new_uid", Active: true}
|
||||
s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { d.Accounts = []domain.Account{old, fresh}; return nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := banking.Session{ID: "consent", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: s.Data.Accounts}
|
||||
a.bank = &historyBank{bankScenario: bankScenario{session: session}}
|
||||
a.ops.Sessions = []banking.Session{session}
|
||||
a.ops.LastSync = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
|
||||
a.ops.AccountSync[old.ID] = a.ops.LastSync
|
||||
after, err := a.Sync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, tx := range after.Data.Transactions {
|
||||
counts[tx.Facts.AccountID]++
|
||||
}
|
||||
if counts[old.ID] != 1 || counts[fresh.ID] != 2 {
|
||||
t.Fatalf("new account history skipped: %v", counts)
|
||||
}
|
||||
}
|
||||
func TestExpiredConsentIsVisibleBeforeNextScheduledSync(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
account := s.Data.Accounts[0]
|
||||
account.ExternalAccountID = "uid"
|
||||
_, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { return SaveAccount(d, account) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.ops.Sessions = []banking.Session{{ID: "expired", ValidUntil: time.Now().Add(-time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}
|
||||
a.ops.Consents["expired"] = Consent{Institution: "ING", Country: "DE"}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.Connections) != 1 || after.Connections[0].Status != "reconnect_required" || after.Connections[0].Institution != "ING" {
|
||||
t.Fatalf("missing bank reconnect status: %+v", after.Connections)
|
||||
}
|
||||
}
|
||||
func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
account := s.Data.Accounts[0]
|
||||
account.ExternalAccountID = "renewed_uid"
|
||||
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "renewed_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}, fetched: make(chan struct{}, 1)}
|
||||
a.bank = b
|
||||
a.ops.LastSync = time.Now().UTC().Format(time.RFC3339)
|
||||
a.authStates["state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE"}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); a.RunScheduler(ctx) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
if err := a.Callback(context.Background(), "one_time_code", "state"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-b.fetched:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("renewal did not wake automatic synchronization")
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.Data.Transactions) != 2 || len(after.Sessions) != 1 || after.Connections[0].Status != "connected" {
|
||||
t.Fatalf("renewed consent not usable: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
type recoveryBank struct{ historyBank }
|
||||
|
||||
func (b *recoveryBank) Status(ctx context.Context, id string) (banking.Session, error) {
|
||||
if id != b.session.ID {
|
||||
return banking.Session{}, banking.ErrReconnect
|
||||
}
|
||||
return b.session, nil
|
||||
}
|
||||
|
||||
func TestInterruptedRenewalDiscardsSupersededConsentDuringRecovery(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
old := s.Data.Accounts[0]
|
||||
old.ExternalAccountID = "old_uid"
|
||||
renewed := old
|
||||
renewed.ExternalAccountID = "new_uid"
|
||||
session := banking.Session{ID: "new_session", ValidUntil: time.Now().Add(time.Hour).Format(time.RFC3339), Accounts: []domain.Account{renewed}}
|
||||
a.bank = &recoveryBank{historyBank{bankScenario: bankScenario{session: session}}}
|
||||
a.ops.Sessions = []banking.Session{{ID: "old_session", Accounts: []domain.Account{old}}, session}
|
||||
after, err := a.Sync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Status.SyncError != "" || len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" {
|
||||
t.Fatalf("superseded consent survived recovery: %+v", after)
|
||||
}
|
||||
if len(after.Data.Transactions) != 2 || after.Connections[0].Status != "connected" {
|
||||
t.Fatal("recovered replacement did not resume imports")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/classification"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
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) Authorize(ctx context.Context, institution, country string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
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{time.Now().Add(15 * time.Minute), institution, country}
|
||||
}
|
||||
return url, err
|
||||
}
|
||||
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}
|
||||
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 != "" {
|
||||
return a.bank.Balances(ctx, account.ExternalAccountID)
|
||||
}
|
||||
}
|
||||
return nil, errors.New("account is not connected")
|
||||
}
|
||||
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{}
|
||||
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 {
|
||||
meta.Error = e.Error()
|
||||
meta.NeedsReconnect = errors.Is(e, banking.ErrReconnect)
|
||||
a.ops.Consents[session.ID] = meta
|
||||
failures = append(failures, meta.Institution+": "+meta.Error)
|
||||
continue
|
||||
}
|
||||
meta.Error = ""
|
||||
meta.NeedsReconnect = false
|
||||
a.ops.Consents[session.ID] = meta
|
||||
a.ops.Sessions[i].ValidUntil = current.ValidUntil
|
||||
for _, account := range current.Accounts {
|
||||
validAccounts[account.ExternalAccountID] = true
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
to := now.Format("2006-01-02")
|
||||
for _, account := range s.Data.Accounts {
|
||||
if !account.Active || account.ExternalAccountID == "" {
|
||||
continue
|
||||
}
|
||||
if !validAccounts[account.ExternalAccountID] {
|
||||
failures = append(failures, account.DisplayName+": bank connection unavailable")
|
||||
continue
|
||||
}
|
||||
from := now.AddDate(0, 0, -90).Format("2006-01-02")
|
||||
if last, e := time.Parse(time.RFC3339, a.ops.AccountSync[account.ID]); e == nil {
|
||||
from = last.AddDate(0, 0, -14).Format("2006-01-02")
|
||||
}
|
||||
facts, e := a.bank.Transactions(ctx, account, from, to)
|
||||
if e != nil {
|
||||
meta := a.ops.Consents[accountSession[account.ID]]
|
||||
meta.Error = "Transaction retrieval failed; retry synchronization"
|
||||
a.ops.Consents[accountSession[account.ID]] = meta
|
||||
failures = append(failures, account.DisplayName+": transaction retrieval failed")
|
||||
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)
|
||||
due := force || err != nil || time.Since(last) >= 24*time.Hour
|
||||
a.mu.Unlock()
|
||||
if configured && due {
|
||||
a.Sync(ctx)
|
||||
timer.Reset(24 * time.Hour)
|
||||
} else {
|
||||
timer.Reset(time.Minute)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
func SaveAccount(d *domain.Dataset, v domain.Account) error {
|
||||
v.DisplayName = strings.TrimSpace(v.DisplayName)
|
||||
if v.ID == "" {
|
||||
v.ID = domain.NewID("acct")
|
||||
}
|
||||
for i, x := range d.Accounts {
|
||||
if x.ID == v.ID {
|
||||
d.Accounts[i] = v
|
||||
return nil
|
||||
}
|
||||
}
|
||||
d.Accounts = append(d.Accounts, v)
|
||||
return nil
|
||||
}
|
||||
func SaveCategory(d *domain.Dataset, v domain.Category) error {
|
||||
v.Name = strings.TrimSpace(v.Name)
|
||||
if v.ID == "" {
|
||||
v.ID = domain.NewID("cat")
|
||||
}
|
||||
for i, x := range d.Categories {
|
||||
if x.ID == v.ID {
|
||||
d.Categories[i] = v
|
||||
return nil
|
||||
}
|
||||
}
|
||||
d.Categories = append(d.Categories, v)
|
||||
return nil
|
||||
}
|
||||
func SaveTag(d *domain.Dataset, v domain.Tag) error {
|
||||
v.Name = strings.TrimSpace(v.Name)
|
||||
if v.ID == "" {
|
||||
v.ID = domain.NewID("tag")
|
||||
}
|
||||
for i, x := range d.Tags {
|
||||
if x.ID == v.ID {
|
||||
d.Tags[i] = v
|
||||
return nil
|
||||
}
|
||||
}
|
||||
d.Tags = append(d.Tags, v)
|
||||
return nil
|
||||
}
|
||||
func SaveMerchant(d *domain.Dataset, v domain.Merchant) error {
|
||||
v.Name = strings.TrimSpace(v.Name)
|
||||
if v.ID == "" {
|
||||
v.ID = domain.NewID("merchant")
|
||||
}
|
||||
for i, x := range d.Merchants {
|
||||
if x.ID == v.ID {
|
||||
d.Merchants[i] = v
|
||||
return nil
|
||||
}
|
||||
}
|
||||
d.Merchants = append(d.Merchants, v)
|
||||
return nil
|
||||
}
|
||||
func replaceIDs(ids []string, from, to string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == from {
|
||||
id = to
|
||||
}
|
||||
if id != "" && !slices.Contains(out, id) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func Manage(d *domain.Dataset, entity, action, id, target string) error {
|
||||
if id == "" || id == target {
|
||||
return errors.New("select distinct source and target")
|
||||
}
|
||||
if action != "delete" && action != "merge" {
|
||||
return errors.New("unknown management action")
|
||||
}
|
||||
if action == "merge" && target == "" {
|
||||
return errors.New("merge target required")
|
||||
}
|
||||
switch entity {
|
||||
case "account":
|
||||
if action != "delete" {
|
||||
return errors.New("account merging is not supported")
|
||||
}
|
||||
for _, t := range d.Transactions {
|
||||
if t.Facts.AccountID == id {
|
||||
return errors.New("account contains immutable financial records; deactivate it instead")
|
||||
}
|
||||
}
|
||||
n := len(d.Accounts)
|
||||
d.Accounts = slices.DeleteFunc(d.Accounts, func(v domain.Account) bool { return v.ID == id })
|
||||
if n == len(d.Accounts) {
|
||||
return errors.New("unknown account")
|
||||
}
|
||||
case "tag":
|
||||
if !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) {
|
||||
return errors.New("unknown tag")
|
||||
}
|
||||
if target != "" && !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == target }) {
|
||||
return errors.New("unknown target tag")
|
||||
}
|
||||
for i := range d.Transactions {
|
||||
d.Transactions[i].Enrichment.TagIDs = replaceIDs(d.Transactions[i].Enrichment.TagIDs, id, target)
|
||||
}
|
||||
for i := range d.Merchants {
|
||||
d.Merchants[i].DefaultTagIDs = replaceIDs(d.Merchants[i].DefaultTagIDs, id, target)
|
||||
}
|
||||
d.Tags = slices.DeleteFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id })
|
||||
case "merchant":
|
||||
source := -1
|
||||
dest := -1
|
||||
for i, v := range d.Merchants {
|
||||
if v.ID == id {
|
||||
source = i
|
||||
}
|
||||
if v.ID == target {
|
||||
dest = i
|
||||
}
|
||||
}
|
||||
if source < 0 {
|
||||
return errors.New("unknown merchant")
|
||||
}
|
||||
if target != "" && dest < 0 {
|
||||
return errors.New("unknown target merchant")
|
||||
}
|
||||
if dest >= 0 {
|
||||
for _, alias := range append(slices.Clone(d.Merchants[source].Aliases), d.Merchants[source].Name) {
|
||||
if !slices.Contains(d.Merchants[dest].Aliases, alias) {
|
||||
d.Merchants[dest].Aliases = append(d.Merchants[dest].Aliases, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range d.Transactions {
|
||||
if d.Transactions[i].Enrichment.MerchantID == id {
|
||||
d.Transactions[i].Enrichment.MerchantID = target
|
||||
}
|
||||
}
|
||||
d.Merchants = slices.DeleteFunc(d.Merchants, func(v domain.Merchant) bool { return v.ID == id })
|
||||
case "category":
|
||||
if id == domain.ExpenseFallback || id == domain.IncomeFallback || id == "cat_expenses" || id == "cat_income" {
|
||||
return errors.New("built-in fallback categories and roots cannot be deleted or merged")
|
||||
}
|
||||
if !slices.ContainsFunc(d.Categories, func(v domain.Category) bool { return v.ID == id }) {
|
||||
return errors.New("unknown category")
|
||||
}
|
||||
removed := map[string]bool{id: true}
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for _, c := range d.Categories {
|
||||
if removed[c.ParentID] && !removed[c.ID] {
|
||||
removed[c.ID] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if action == "delete" && len(removed) > 1 {
|
||||
return errors.New("move or delete child categories first, or merge the subtree")
|
||||
}
|
||||
if removed[target] {
|
||||
return errors.New("cannot migrate into the removed subtree")
|
||||
}
|
||||
if target != "" {
|
||||
if !slices.ContainsFunc(d.Categories, func(v domain.Category) bool { return v.ID == target }) {
|
||||
return errors.New("unknown target category")
|
||||
}
|
||||
for _, c := range d.Categories {
|
||||
if c.ParentID == target {
|
||||
return errors.New("migration target must be a leaf category")
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range d.Transactions {
|
||||
if removed[d.Transactions[i].Enrichment.CategoryID] {
|
||||
if target == "" {
|
||||
return errors.New("category is referenced; select a migration target")
|
||||
}
|
||||
d.Transactions[i].Enrichment.CategoryID = target
|
||||
}
|
||||
}
|
||||
for i := range d.Merchants {
|
||||
if removed[d.Merchants[i].DefaultCategoryID] {
|
||||
if target == "" {
|
||||
return errors.New("merchant defaults reference this category; select a migration target")
|
||||
}
|
||||
d.Merchants[i].DefaultCategoryID = target
|
||||
}
|
||||
}
|
||||
d.Categories = slices.DeleteFunc(d.Categories, func(v domain.Category) bool { return removed[v.ID] })
|
||||
default:
|
||||
return fmt.Errorf("unknown entity %q", entity)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
type Fields struct {
|
||||
Merchant bool `json:"merchant"`
|
||||
Category bool `json:"category"`
|
||||
Tags bool `json:"tags"`
|
||||
}
|
||||
type PreviewRequest struct {
|
||||
Revision string `json:"revision"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Model string `json:"model"`
|
||||
Fields Fields `json:"fields"`
|
||||
}
|
||||
type Change struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Before domain.Enrichment `json:"before"`
|
||||
After domain.Enrichment `json:"after"`
|
||||
}
|
||||
type ClassificationError struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
type Preview struct {
|
||||
ID string `json:"id"`
|
||||
Revision string `json:"revision"`
|
||||
Changes []Change `json:"changes"`
|
||||
Analysed int `json:"analysed"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
Errors []ClassificationError `json:"errors"`
|
||||
NewMerchants []domain.Merchant `json:"new_merchants"`
|
||||
created time.Time
|
||||
}
|
||||
|
||||
func validRange(from, to string) error {
|
||||
f, e := time.Parse("2006-01-02", from)
|
||||
if e != nil {
|
||||
return errors.New("from must be YYYY-MM-DD")
|
||||
}
|
||||
t, e := time.Parse("2006-01-02", to)
|
||||
if e != nil {
|
||||
return errors.New("to must be YYYY-MM-DD")
|
||||
}
|
||||
if f.After(t) {
|
||||
return errors.New("from must not exceed to")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
||||
if err := validRange(r.From, r.To); err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
if !r.Fields.Merchant && !r.Fields.Category && !r.Fields.Tags {
|
||||
return Preview{}, errors.New("select at least one enrichment field")
|
||||
}
|
||||
if strings.TrimSpace(r.Model) == "" {
|
||||
return Preview{}, errors.New("model is required")
|
||||
}
|
||||
a.mu.Lock()
|
||||
s, err := a.snapshot(ctx)
|
||||
client := a.classifier
|
||||
a.mu.Unlock()
|
||||
if err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
if r.Revision != s.Revision {
|
||||
return Preview{}, errors.New("revision conflict: reload before analysing")
|
||||
}
|
||||
client.Model = r.Model
|
||||
p := Preview{ID: domain.NewID("preview"), Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
|
||||
baseMerchants := len(s.Data.Merchants)
|
||||
for _, t := range s.Data.Transactions {
|
||||
if t.Facts.BookingDate < r.From || t.Facts.BookingDate > r.To || t.Enrichment.Kind == "transfer" {
|
||||
continue
|
||||
}
|
||||
if err = ctx.Err(); err != nil {
|
||||
return Preview{}, err
|
||||
}
|
||||
p.Analysed++
|
||||
proposal, e := client.Classify(ctx, t.Facts, s.Data, true)
|
||||
if e != nil {
|
||||
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
||||
continue
|
||||
}
|
||||
after := t.Enrichment
|
||||
if r.Fields.Merchant {
|
||||
after.MerchantID = proposal.Enrichment.MerchantID
|
||||
if e = addProposal(&s.Data, proposal); e != nil {
|
||||
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
if r.Fields.Category {
|
||||
after.CategoryID = proposal.Enrichment.CategoryID
|
||||
}
|
||||
if r.Fields.Tags {
|
||||
after.TagIDs = slices.Clone(proposal.Enrichment.TagIDs)
|
||||
}
|
||||
if e = domain.ValidateEnrichment(s.Data, t.Facts, after); e != nil {
|
||||
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
||||
continue
|
||||
}
|
||||
beforeComparable, afterComparable := t.Enrichment, after
|
||||
beforeComparable.Classification = domain.Provenance{}
|
||||
afterComparable.Classification = domain.Provenance{}
|
||||
beforeComparable.TagIDs = slices.Clone(beforeComparable.TagIDs)
|
||||
afterComparable.TagIDs = slices.Clone(afterComparable.TagIDs)
|
||||
slices.Sort(beforeComparable.TagIDs)
|
||||
slices.Sort(afterComparable.TagIDs)
|
||||
if reflect.DeepEqual(beforeComparable, afterComparable) {
|
||||
p.Unchanged++
|
||||
continue
|
||||
}
|
||||
after.Classification = proposal.Enrichment.Classification
|
||||
p.Changes = append(p.Changes, Change{t.Facts.ID, t.Facts.RawDescription, t.Enrichment, after})
|
||||
}
|
||||
p.NewMerchants = append([]domain.Merchant{}, s.Data.Merchants[baseMerchants:]...)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for id, old := range a.previews {
|
||||
if time.Since(old.created) > time.Hour {
|
||||
delete(a.previews, id)
|
||||
}
|
||||
}
|
||||
if len(a.previews) >= 20 {
|
||||
return Preview{}, errors.New("too many active previews; cancel one first")
|
||||
}
|
||||
a.previews[p.ID] = p
|
||||
return p, nil
|
||||
}
|
||||
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
p, ok := a.previews[id]
|
||||
if !ok || time.Since(p.created) > time.Hour {
|
||||
return State{}, errors.New("preview expired or unknown; analyse again")
|
||||
}
|
||||
if rev != p.Revision {
|
||||
return State{}, errors.New("revision conflict: preview was generated from different records")
|
||||
}
|
||||
s, err := a.snapshot(ctx)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if s.Revision != rev {
|
||||
return State{}, errors.New("revision conflict: data changed after preview; analyse again")
|
||||
}
|
||||
changes := map[string]domain.Enrichment{}
|
||||
for _, c := range p.Changes {
|
||||
changes[c.ID] = c.After
|
||||
}
|
||||
selected := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if _, ok := changes[id]; !ok {
|
||||
return State{}, errors.New("selected transaction is not in preview")
|
||||
}
|
||||
selected[id] = true
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return State{}, errors.New("select at least one change")
|
||||
}
|
||||
needed := map[string]bool{}
|
||||
for i, t := range s.Data.Transactions {
|
||||
if selected[t.Facts.ID] {
|
||||
s.Data.Transactions[i].Enrichment = changes[t.Facts.ID]
|
||||
needed[changes[t.Facts.ID].MerchantID] = true
|
||||
}
|
||||
}
|
||||
for _, m := range p.NewMerchants {
|
||||
if needed[m.ID] {
|
||||
s.Data.Merchants = append(s.Data.Merchants, m)
|
||||
}
|
||||
}
|
||||
state, err := a.commit(ctx, rev, s.Data)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
delete(a.previews, id)
|
||||
return state, nil
|
||||
}
|
||||
func (a *App) CancelPreview(id string) { a.mu.Lock(); defer a.mu.Unlock(); delete(a.previews, id) }
|
||||
@@ -0,0 +1,106 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
type bankScenario struct {
|
||||
session banking.Session
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) {
|
||||
return "https://bank.example/authorize", nil
|
||||
}
|
||||
func (b *bankScenario) Exchange(context.Context, string) (banking.Session, error) {
|
||||
return b.session, nil
|
||||
}
|
||||
func (b *bankScenario) Status(context.Context, string) (banking.Session, error) {
|
||||
if b.fail {
|
||||
return banking.Session{}, errors.New("expired")
|
||||
}
|
||||
return b.session, nil
|
||||
}
|
||||
func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) {
|
||||
return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil
|
||||
}
|
||||
func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string) ([]domain.Facts, error) {
|
||||
if b.fail {
|
||||
return nil, errors.New("offline")
|
||||
}
|
||||
return []domain.Facts{{Source: "enablebanking", AccountID: a.ID, BookingDate: time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02"), Amount: "-42.80", Currency: "EUR", RawDescription: "REWE", ExternalID: "entry_stable"}}, nil
|
||||
}
|
||||
func TestSyncRestoresSavedConsentBindingsAndDoesNotDuplicateFacts(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
account := s.Data.Accounts[0]
|
||||
account.ExternalAccountID = "provider_new"
|
||||
account.IBAN = "DE89370400440532013000"
|
||||
provider := &bankScenario{session: banking.Session{ID: "new_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}
|
||||
a.bank = provider
|
||||
a.ops.Sessions = []banking.Session{provider.session}
|
||||
if err := a.saveOps(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := a.Sync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.Data.Accounts) != 1 || first.Data.Accounts[0].ExternalAccountID != "provider_new" || len(first.Data.Transactions) != 1 {
|
||||
t.Fatalf("saved session did not recover/import: %+v", first.Data)
|
||||
}
|
||||
again, err := a.Sync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(first.Data, again.Data) {
|
||||
t.Fatal("repeated bank synchronization changed canonical financial data")
|
||||
}
|
||||
provider.fail = true
|
||||
failed, err := a.Sync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failed.Status.SyncError == "" || !reflect.DeepEqual(again.Data, failed.Data) {
|
||||
t.Fatal("provider failure was not isolated from canonical data")
|
||||
}
|
||||
}
|
||||
func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
account := s.Data.Accounts[0]
|
||||
account.ExternalAccountID = "old_uid"
|
||||
account.IBAN = "DE89370400440532013000"
|
||||
s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { return SaveAccount(d, account) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.ops.Sessions = []banking.Session{{ID: "old_session", Accounts: []domain.Account{account}}}
|
||||
renewed := account
|
||||
renewed.ID = "provider_local_id"
|
||||
renewed.ExternalAccountID = "new_uid"
|
||||
renewed.DisplayName = "Bank-generated name"
|
||||
a.bank = &bankScenario{session: banking.Session{ID: "new_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{renewed}}}
|
||||
a.authStates["one_time_state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE"}
|
||||
if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.Data.Accounts) != 1 || after.Data.Accounts[0].ID != account.ID || after.Data.Accounts[0].DisplayName != account.DisplayName || after.Data.Accounts[0].ExternalAccountID != "new_uid" {
|
||||
t.Fatal("reconnect duplicated account or lost local display name")
|
||||
}
|
||||
if len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" {
|
||||
t.Fatal("expired session remains active after reconnect")
|
||||
}
|
||||
if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err == nil {
|
||||
t.Fatal("authorization state replay was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user