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.WithModel(r.Model) a.mu.Unlock() if err != nil { return Preview{}, err } if r.Revision != s.Revision { return Preview{}, errors.New("revision conflict: reload before analysing") } 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 err = ctx.Err(); err != nil { return Preview{}, err } 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) }