Compare commits
2
Commits
da817078f4
...
9092c5721d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9092c5721d | ||
|
|
635c11be56 |
+25
-9
@@ -443,17 +443,26 @@ The share column is signed only for corporate actions and depot transfers. Buys
|
|||||||
and sells are unsigned and take their direction from the type. Both conventions
|
and sells are unsigned and take their direction from the type. Both conventions
|
||||||
are resolved at import, once.
|
are resolved at import, once.
|
||||||
|
|
||||||
Every security row is checked against shares times price, to the precision the
|
Every security row is checked against shares times price, allowing for the
|
||||||
export stated the amount at and no further. One export prints the exact product
|
rounding the export's own printed figures propagate. Both ends are rounded and
|
||||||
to nine places, and the check is then exact. Another prints the notional rounded
|
neither states by how much: one export prints the notional to the cent, so
|
||||||
to cents, where demanding exactness rejects every trade whose product does not
|
0,426581 shares at 63,06 settle as 26,90 where the product is 26,90019786;
|
||||||
land on a whole cent - measured on a real export, 29 of 59 of them. One unit of
|
another prints a price to fewer places than the fill actually had, settling six
|
||||||
the stated precision is still four orders of magnitude tighter than the
|
NVIDIA shares at 808,5599 against a printed 134,76 whose product is 808,56.
|
||||||
misplaced decimal separator this check exists to catch.
|
The allowance is half a unit of the gross's stated precision plus one part in a
|
||||||
|
hundred thousand of the gross. Measured over a complete real export of 88
|
||||||
|
security rows, exactly one deviates at all, by one part in eight million.
|
||||||
|
|
||||||
|
What that still refuses: a price taken from the wrong share class, and the lost
|
||||||
|
decimal separator the check exists for, four orders of magnitude out. What it
|
||||||
|
accepts: the broker's own rounding, including a whole cent once a gross stated
|
||||||
|
to the cent passes about five hundred euro, where a real one-cent error cannot
|
||||||
|
be told from that rounding.
|
||||||
|
|
||||||
It cannot catch a separator lost uniformly across a row: 1 x 25,795 and
|
It cannot catch a separator lost uniformly across a row: 1 x 25,795 and
|
||||||
1 x 25795 both satisfy it. A price cross-check against an outside provider is
|
1 x 25795 both satisfy it. A price cross-check against an outside provider is
|
||||||
the only remedy and is deliberately not implemented.
|
the only remedy and is deliberately not implemented. A spreadsheet round-trip
|
||||||
|
is what strips those separators, so import the broker's original file.
|
||||||
|
|
||||||
Rejected whole, with the record number: an unknown status, an unknown type, a
|
Rejected whole, with the record number: an unknown status, an unknown type, a
|
||||||
classifying column that disagrees with its type, an account type other than the
|
classifying column that disagrees with its type, an account type other than the
|
||||||
@@ -664,7 +673,14 @@ canonical data and the index error is surfaced rather than serving stale totals.
|
|||||||
Reclassification
|
Reclassification
|
||||||
----------------
|
----------------
|
||||||
AI / Classification: choose dates, model and independent Merchant/Category/Tags
|
AI / Classification: choose dates, model and independent Merchant/Category/Tags
|
||||||
fields. Analyse produces a read-only preview. Apply all/selected writes all
|
fields. Analyse starts a background run and reports live progress: analysed
|
||||||
|
count, proposed changes, and per-transaction errors as they happen. Requests
|
||||||
|
stay paced seconds apart, so a large range takes minutes; the page may be left
|
||||||
|
and revisited, and Stop abandons the run without writing anything. A run that
|
||||||
|
has produced no successful proposal and fails three times in a row with the
|
||||||
|
same error stops early and reports that error instead of repeating it across
|
||||||
|
the whole range. Only one run exists at a time.
|
||||||
|
The finished run is a read-only preview. Apply all/selected writes all
|
||||||
selected changes in one canonical commit; financial facts never change. A
|
selected changes in one canonical commit; financial facts never change. A
|
||||||
manual edit, external journal change or taxonomy change invalidates old previews.
|
manual edit, external journal change or taxonomy change invalidates old previews.
|
||||||
Previews are kept in memory for up to one hour and disappear on restart. Cancel
|
Previews are kept in memory for up to one hour and disappear on restart. Cancel
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ Because a cash row's amount already includes the tax the broker withheld or refu
|
|||||||
|
|
||||||
Two more traps there: a `DIVIDEND` row fills the share column with **the holding the dividend was paid on**, so adding it would double the position; and crypto carries a bare ticker like `DOGE` in `symbol`, with its real identifier only in the description. Both are handled, and a position row that resolves to neither is refused.
|
Two more traps there: a `DIVIDEND` row fills the share column with **the holding the dividend was paid on**, so adding it would double the position; and crypto carries a bare ticker like `DOGE` in `symbol`, with its real identifier only in the description. Both are handled, and a position row that resolves to neither is refused.
|
||||||
|
|
||||||
Only `Executed` rows import from Scalable: a cancelled retry is all zeros, so it passes every arithmetic check and would otherwise become a phantom trade. Every security row is verified against shares × price **to the precision the broker stated the amount at** — exactly, where the export prints the full product; to within a cent, where it prints the notional rounded. An unknown row type, a mismatched classifying column, a foreign settlement currency, an unresolvable security, or a failed check rejects the **whole file** with the record number, because each of those can move money that never moved.
|
Only `Executed` rows import from Scalable: a cancelled retry is all zeros, so it passes every arithmetic check and would otherwise become a phantom trade. Every security row is verified against shares × price, **allowing for the rounding the export's own figures propagate** — both the gross and the price are printed rounded, and neither says by how much. Across a complete real export of 88 security rows exactly one deviates at all, by one part in eight million; a misplaced decimal separator is four orders of magnitude outside the allowance. An unknown row type, a mismatched classifying column, a foreign settlement currency, an unresolvable security, or a failed check rejects the **whole file** with the record number, because each of those can move money that never moved.
|
||||||
|
|
||||||
Securities are registered by **ISIN** in **Instruments**. The ISIN is the identity; the name is editable display text, because one ISIN appears under several broker names over the years. Crypto is held under the ISIN-shaped identifier the broker issues for it. Set the account's **settlement IBAN** for an export that names no counterparty of its own, so deposits from your bank pair with the funding account instead of staying unpaired. They never become income either way — a broker record is excluded from spending and income analytics, from bulk reclassification, and from the AI entirely.
|
Securities are registered by **ISIN** in **Instruments**. The ISIN is the identity; the name is editable display text, because one ISIN appears under several broker names over the years. Crypto is held under the ISIN-shaped identifier the broker issues for it. Set the account's **settlement IBAN** for an export that names no counterparty of its own, so deposits from your bank pair with the funding account instead of staying unpaired. They never become income either way — a broker record is excluded from spending and income analytics, from bulk reclassification, and from the AI entirely.
|
||||||
|
|
||||||
@@ -417,6 +417,8 @@ Every AI classification requests `provider.data_collection = "deny"`, `provider.
|
|||||||
|
|
||||||
Classification spaces request starts by at least **three seconds**, including successful requests, rather than sending a burst between 429s. This is a conservative application policy, not a published quota for every model. On HTTP 429, backoff starts at **15 seconds** and increases across consecutive failures; `Retry-After` seconds or HTTP dates can extend the wait. Successful retries retain the learned spacing (up to **30 seconds**) instead of immediately bursting again. Each operation makes at most **four attempts**, with at most **two minutes of automatic retry waiting**, preserving the same model, sanitized prompt, and privacy controls. Imports and previews share this pacing and cooldown. Long or exhausted limits leave records unclassified with a retry-time error; local merchant rules still work. After the cooldown, run **AI classification → Analyse** again for previously failed records—repeating a bank import does not reclassify existing transactions.
|
Classification spaces request starts by at least **three seconds**, including successful requests, rather than sending a burst between 429s. This is a conservative application policy, not a published quota for every model. On HTTP 429, backoff starts at **15 seconds** and increases across consecutive failures; `Retry-After` seconds or HTTP dates can extend the wait. Successful retries retain the learned spacing (up to **30 seconds**) instead of immediately bursting again. Each operation makes at most **four attempts**, with at most **two minutes of automatic retry waiting**, preserving the same model, sanitized prompt, and privacy controls. Imports and previews share this pacing and cooldown. Long or exhausted limits leave records unclassified with a retry-time error; local merchant rules still work. After the cooldown, run **AI classification → Analyse** again for previously failed records—repeating a bank import does not reclassify existing transactions.
|
||||||
|
|
||||||
|
**AI classification → Analyse** runs in the background: the page shows how many transactions have been analysed, proposed changes, and every per-transaction failure as it happens, with a **Stop** button that abandons the run without writing anything. You can navigate away and return; the run keeps building and the page re-attaches to it. A run that has produced no successful result and fails **three times in a row with the same error** stops early and reports that error — a wrong key or an unsupported model surfaces within seconds instead of repeating across the whole range.
|
||||||
|
|
||||||
## Data, backups, and recovery
|
## Data, backups, and recovery
|
||||||
|
|
||||||
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
|
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type App struct {
|
|||||||
bank banking.Provider
|
bank banking.Provider
|
||||||
classifier classification.Client
|
classifier classification.Client
|
||||||
previews map[string]Preview
|
previews map[string]Preview
|
||||||
|
previewRun *previewJob
|
||||||
taxonomies map[string]TaxonomyPreview
|
taxonomies map[string]TaxonomyPreview
|
||||||
csvImports map[string]CSVImport
|
csvImports map[string]CSVImport
|
||||||
authStates map[string]authorization
|
authStates map[string]authorization
|
||||||
|
|||||||
+51
-10
@@ -129,6 +129,32 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runPreview drives the background preview job to completion the way the UI
|
||||||
|
// does: start the run, then poll progress until it reports done.
|
||||||
|
func runPreview(t *testing.T, a *App, r PreviewRequest) (Preview, error) {
|
||||||
|
t.Helper()
|
||||||
|
start, err := a.StartPreview(context.Background(), r)
|
||||||
|
if err != nil {
|
||||||
|
return Preview{}, err
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for {
|
||||||
|
p, err := a.PreviewProgress(start.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Preview{}, err
|
||||||
|
}
|
||||||
|
if p.Done {
|
||||||
|
if p.Error != "" {
|
||||||
|
return Preview{}, errors.New(p.Error)
|
||||||
|
}
|
||||||
|
return *p.Preview, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("preview run did not finish")
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
||||||
a, s := testApp(t)
|
a, s := testApp(t)
|
||||||
s = seed(t, a, s)
|
s = seed(t, a, s)
|
||||||
@@ -145,7 +171,7 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
for _, model := range []string{"test/model", "test/another-model"} {
|
for _, model := range []string{"test/model", "test/another-model"} {
|
||||||
preview, err := a.Preview(ctx, PreviewRequest{
|
preview, err := runPreview(t, a, PreviewRequest{
|
||||||
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
|
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
|
||||||
Model: model, Fields: Fields{Category: true},
|
Model: model, Fields: Fields{Category: true},
|
||||||
})
|
})
|
||||||
@@ -185,24 +211,39 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCancelledLastClassificationDoesNotProducePreview(t *testing.T) {
|
func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
|
||||||
a, s := testApp(t)
|
a, s := testApp(t)
|
||||||
s = seed(t, a, s)
|
s = seed(t, a, s)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ids := make(chan string, 1)
|
||||||
defer cancel()
|
|
||||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Stop the run from within its first provider call, as the UI's Stop
|
||||||
|
// button would mid-request.
|
||||||
|
a.CancelPreview(<-ids)
|
||||||
w.Header().Set("Retry-After", "60")
|
w.Header().Set("Retry-After", "60")
|
||||||
w.WriteHeader(http.StatusTooManyRequests)
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
cancel()
|
|
||||||
}))
|
}))
|
||||||
defer provider.Close()
|
defer provider.Close()
|
||||||
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
|
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
|
||||||
p, err := a.Preview(ctx, PreviewRequest{
|
start, err := a.StartPreview(context.Background(), PreviewRequest{
|
||||||
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
|
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
|
||||||
Model: "test/model", Fields: Fields{Category: true},
|
Model: "test/model", Fields: Fields{Category: true},
|
||||||
})
|
})
|
||||||
if !errors.Is(err, context.Canceled) || p.ID != "" {
|
if err != nil {
|
||||||
t.Fatalf("cancelled final record produced a preview: id=%q, error=%v", p.ID, err)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids <- start.ID
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
if _, err := a.PreviewProgress(start.ID); err != nil {
|
||||||
|
break // the cancelled run is gone, never a finished preview
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("cancelled preview run still reports progress")
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil {
|
||||||
|
t.Fatal("cancelled run produced an applicable preview")
|
||||||
}
|
}
|
||||||
after, err := a.Snapshot(context.Background())
|
after, err := a.Snapshot(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,7 +302,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
|||||||
}
|
}
|
||||||
mockClassifier(t, a)
|
mockClassifier(t, a)
|
||||||
before := domain.Clone(s.Data)
|
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}})
|
preview, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -306,7 +347,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
|||||||
a, s := testApp(t)
|
a, s := testApp(t)
|
||||||
s = seed(t, a, s)
|
s = seed(t, a, s)
|
||||||
mockClassifier(t, a)
|
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}})
|
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
|
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
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}})
|
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-25
@@ -3,11 +3,13 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"finance-duck/internal/classification"
|
||||||
"finance-duck/internal/domain"
|
"finance-duck/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,6 +46,33 @@ type Preview struct {
|
|||||||
created time.Time
|
created time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreviewProgress is the live state of one preview run. Errors accumulate as
|
||||||
|
// they happen so a failing provider is visible after seconds, not after the
|
||||||
|
// whole paced range. Preview is set only when Done with an empty Error.
|
||||||
|
type PreviewProgress struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Analysed int `json:"analysed"`
|
||||||
|
Changes int `json:"changes"`
|
||||||
|
Unchanged int `json:"unchanged"`
|
||||||
|
Errors []ClassificationError `json:"errors"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Preview *Preview `json:"preview,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p PreviewProgress) clone() PreviewProgress {
|
||||||
|
p.Errors = append([]ClassificationError{}, p.Errors...)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewJob is the single in-flight (or most recently finished) preview run.
|
||||||
|
// status is guarded by App.mu; cancel stops the goroutine cooperatively.
|
||||||
|
type previewJob struct {
|
||||||
|
cancel context.CancelFunc
|
||||||
|
status PreviewProgress
|
||||||
|
}
|
||||||
|
|
||||||
func validRange(from, to string) error {
|
func validRange(from, to string) error {
|
||||||
f, e := time.Parse("2006-01-02", from)
|
f, e := time.Parse("2006-01-02", from)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -58,49 +87,162 @@ func validRange(from, to string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
func validatePreviewRequest(r PreviewRequest) error {
|
||||||
if err := validRange(r.From, r.To); err != nil {
|
if err := validRange(r.From, r.To); err != nil {
|
||||||
return Preview{}, err
|
return err
|
||||||
}
|
}
|
||||||
if !r.Fields.Merchant && !r.Fields.Category && !r.Fields.Tags {
|
if !r.Fields.Merchant && !r.Fields.Category && !r.Fields.Tags {
|
||||||
return Preview{}, errors.New("select at least one enrichment field")
|
return errors.New("select at least one enrichment field")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(r.Model) == "" {
|
if strings.TrimSpace(r.Model) == "" {
|
||||||
return Preview{}, errors.New("model is required")
|
return errors.New("model is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func previewEligible(t domain.Transaction, r PreviewRequest) bool {
|
||||||
|
return t.Facts.BookingDate >= r.From && t.Facts.BookingDate <= r.To &&
|
||||||
|
t.Enrichment.Kind != "transfer" && t.Enrichment.Kind != domain.KindInvestment
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPreview validates the request against the current journal and starts a
|
||||||
|
// background classification run. The provider is paced to one request every
|
||||||
|
// few seconds, so any real range takes minutes: the caller polls
|
||||||
|
// PreviewProgress instead of holding an HTTP request open for the duration.
|
||||||
|
// Only one run exists at a time; the run owns its own snapshot and never
|
||||||
|
// touches canonical data.
|
||||||
|
func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) {
|
||||||
|
if err := validatePreviewRequest(r); err != nil {
|
||||||
|
return PreviewProgress{}, err
|
||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.previewRun != nil && !a.previewRun.status.Done {
|
||||||
|
return PreviewProgress{}, errors.New("a preview is already being generated; stop it first")
|
||||||
|
}
|
||||||
s, err := a.snapshot(ctx)
|
s, err := a.snapshot(ctx)
|
||||||
client := a.classifier.WithModel(r.Model)
|
|
||||||
a.mu.Unlock()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Preview{}, err
|
return PreviewProgress{}, err
|
||||||
}
|
}
|
||||||
if r.Revision != s.Revision {
|
if r.Revision != s.Revision {
|
||||||
return Preview{}, errors.New("revision conflict: reload before analysing")
|
return PreviewProgress{}, errors.New("revision conflict: reload before analysing")
|
||||||
}
|
}
|
||||||
p := Preview{ID: domain.NewID("preview"), Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
|
client := a.classifier.WithModel(r.Model)
|
||||||
baseMerchants := len(s.Data.Merchants)
|
total := 0
|
||||||
for _, t := range s.Data.Transactions {
|
for _, t := range s.Data.Transactions {
|
||||||
if t.Facts.BookingDate < r.From || t.Facts.BookingDate > r.To || t.Enrichment.Kind == "transfer" || t.Enrichment.Kind == domain.KindInvestment {
|
if previewEligible(t, r) {
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
job := &previewJob{cancel: cancel, status: PreviewProgress{ID: domain.NewID("preview"), Total: total, Errors: []ClassificationError{}}}
|
||||||
|
a.previewRun = job
|
||||||
|
go a.runPreview(runCtx, cancel, client, s, r, job)
|
||||||
|
return job.status.clone(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runPreview(ctx context.Context, cancel context.CancelFunc, client *classification.Client, s State, r PreviewRequest, job *previewJob) {
|
||||||
|
defer cancel()
|
||||||
|
p, err := classifyRange(ctx, client, s, r, job.status.ID, func(u PreviewProgress) {
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.previewRun == job {
|
||||||
|
job.status = u
|
||||||
|
}
|
||||||
|
a.mu.Unlock()
|
||||||
|
})
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if a.previewRun != job {
|
||||||
|
return // stopped by CancelPreview; discard the result
|
||||||
|
}
|
||||||
|
job.status.Done = true
|
||||||
|
if err != nil {
|
||||||
|
job.status.Error = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for id, old := range a.previews {
|
||||||
|
if time.Since(old.created) > time.Hour {
|
||||||
|
delete(a.previews, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(a.previews) >= 20 {
|
||||||
|
job.status.Error = "too many active previews; cancel one first"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.previews[p.ID] = p
|
||||||
|
job.status.Analysed = p.Analysed
|
||||||
|
job.status.Changes = len(p.Changes)
|
||||||
|
job.status.Unchanged = p.Unchanged
|
||||||
|
job.status.Errors = append([]ClassificationError{}, p.Errors...)
|
||||||
|
job.status.Preview = &p
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewProgress reports the current (or most recently finished) preview run.
|
||||||
|
// An empty id re-attaches to whatever run exists, so navigating away from the
|
||||||
|
// page does not orphan a run that is still spending provider requests.
|
||||||
|
func (a *App) PreviewProgress(id string) (PreviewProgress, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
job := a.previewRun
|
||||||
|
if job == nil || (id != "" && job.status.ID != id) {
|
||||||
|
return PreviewProgress{}, errors.New("no matching preview run; analyse again")
|
||||||
|
}
|
||||||
|
return job.status.clone(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyRange proposes enrichment for every eligible transaction in the
|
||||||
|
// snapshot, reporting progress after each one. It stops early when the run has
|
||||||
|
// produced no successful proposal yet and the same error message repeats three
|
||||||
|
// times in a row: an identical repeated failure is a configuration or provider
|
||||||
|
// problem, and grinding through the rest of the paced range would only repeat
|
||||||
|
// it a few seconds apart.
|
||||||
|
func classifyRange(ctx context.Context, client *classification.Client, s State, r PreviewRequest, id string, report func(PreviewProgress)) (Preview, error) {
|
||||||
|
p := Preview{ID: id, Revision: s.Revision, Changes: []Change{}, Errors: []ClassificationError{}, created: time.Now()}
|
||||||
|
baseMerchants := len(s.Data.Merchants)
|
||||||
|
total := 0
|
||||||
|
for _, t := range s.Data.Transactions {
|
||||||
|
if previewEligible(t, r) {
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress := func() {
|
||||||
|
if report != nil {
|
||||||
|
report(PreviewProgress{ID: id, Total: total, Analysed: p.Analysed, Changes: len(p.Changes), Unchanged: p.Unchanged, Errors: append([]ClassificationError{}, p.Errors...)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
succeeded := false
|
||||||
|
repeated := 0
|
||||||
|
for _, t := range s.Data.Transactions {
|
||||||
|
if !previewEligible(t, r) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err = ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return Preview{}, err
|
return Preview{}, err
|
||||||
}
|
}
|
||||||
p.Analysed++
|
p.Analysed++
|
||||||
proposal, e := client.Classify(ctx, t.Facts, s.Data, true)
|
proposal, e := client.Classify(ctx, t.Facts, s.Data, true)
|
||||||
if err = ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return Preview{}, err
|
return Preview{}, err
|
||||||
}
|
}
|
||||||
if e != nil {
|
if e != nil {
|
||||||
|
if n := len(p.Errors); n > 0 && p.Errors[n-1].Error == e.Error() {
|
||||||
|
repeated++
|
||||||
|
} else {
|
||||||
|
repeated = 1
|
||||||
|
}
|
||||||
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
||||||
|
if !succeeded && repeated >= 3 {
|
||||||
|
return Preview{}, fmt.Errorf("stopped after %d identical failures — %s — with %d of %d transactions not analysed", repeated, e.Error(), total-p.Analysed, total)
|
||||||
|
}
|
||||||
|
progress()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
succeeded = true
|
||||||
after := t.Enrichment
|
after := t.Enrichment
|
||||||
if r.Fields.Merchant {
|
if r.Fields.Merchant {
|
||||||
after.MerchantID = proposal.Enrichment.MerchantID
|
after.MerchantID = proposal.Enrichment.MerchantID
|
||||||
if e = addProposal(&s.Data, proposal, t.Facts); e != nil {
|
if e = addProposal(&s.Data, proposal, t.Facts); e != nil {
|
||||||
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
||||||
|
progress()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +254,7 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
|||||||
}
|
}
|
||||||
if e = domain.ValidateEnrichment(s.Data, t.Facts, after); e != nil {
|
if e = domain.ValidateEnrichment(s.Data, t.Facts, after); e != nil {
|
||||||
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
p.Errors = append(p.Errors, ClassificationError{t.Facts.ID, e.Error()})
|
||||||
|
progress()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
beforeComparable, afterComparable := t.Enrichment, after
|
beforeComparable, afterComparable := t.Enrichment, after
|
||||||
@@ -123,23 +266,14 @@ func (a *App) Preview(ctx context.Context, r PreviewRequest) (Preview, error) {
|
|||||||
slices.Sort(afterComparable.TagIDs)
|
slices.Sort(afterComparable.TagIDs)
|
||||||
if reflect.DeepEqual(beforeComparable, afterComparable) {
|
if reflect.DeepEqual(beforeComparable, afterComparable) {
|
||||||
p.Unchanged++
|
p.Unchanged++
|
||||||
|
progress()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
after.Classification = proposal.Enrichment.Classification
|
after.Classification = proposal.Enrichment.Classification
|
||||||
p.Changes = append(p.Changes, Change{t.Facts.ID, t.Facts.RawDescription, t.Enrichment, after})
|
p.Changes = append(p.Changes, Change{t.Facts.ID, t.Facts.RawDescription, t.Enrichment, after})
|
||||||
|
progress()
|
||||||
}
|
}
|
||||||
p.NewMerchants = append([]domain.Merchant{}, s.Data.Merchants[baseMerchants:]...)
|
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
|
return p, nil
|
||||||
}
|
}
|
||||||
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
|
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
|
||||||
@@ -198,6 +332,20 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
|
|||||||
return State{}, err
|
return State{}, err
|
||||||
}
|
}
|
||||||
delete(a.previews, id)
|
delete(a.previews, id)
|
||||||
|
if job := a.previewRun; job != nil && job.status.ID == id {
|
||||||
|
a.previewRun = nil
|
||||||
|
}
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
func (a *App) CancelPreview(id string) { a.mu.Lock(); defer a.mu.Unlock(); delete(a.previews, id) }
|
|
||||||
|
// CancelPreview stops a running preview job and discards a finished preview.
|
||||||
|
// A run and its stored preview share one id, so a single cancel covers both.
|
||||||
|
func (a *App) CancelPreview(id string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if job := a.previewRun; job != nil && job.status.ID == id {
|
||||||
|
job.cancel()
|
||||||
|
a.previewRun = nil
|
||||||
|
}
|
||||||
|
delete(a.previews, id)
|
||||||
|
}
|
||||||
|
|||||||
@@ -228,6 +228,38 @@ func TestSingleShareRowCatchesOnlyInconsistentArithmetic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A broker's own gross can disagree with its own printed shares times price,
|
||||||
|
// because the price is printed to fewer places than the fill actually had.
|
||||||
|
// Six NVIDIA shares settled at 808.5599 against a printed 134.76, whose
|
||||||
|
// product is 808.56: one ten-thousandth out, and the whole file was refused.
|
||||||
|
// The rounding the printed figures propagate is allowed; anything above one
|
||||||
|
// part in a hundred thousand still is not.
|
||||||
|
func TestRoundedPriceDoesNotRejectTheBrokersOwnGross(t *testing.T) {
|
||||||
|
const row = `2025-01-09;10:37:32;Executed;SCALixkS3TomjQv;NVIDIA;Security;Buy;US67066G1040;6;134,76;-808,5599;0,00;0,00;EUR`
|
||||||
|
result := readBroker(t, row)
|
||||||
|
inv := result.Facts[0].Investment
|
||||||
|
if inv.Gross != "-808.5599" || inv.Price != "134.76" || inv.Quantity != "6" {
|
||||||
|
t.Fatalf("trade read as %+v", inv)
|
||||||
|
}
|
||||||
|
if got := result.Facts[0].Amount; got != "-808.5599" {
|
||||||
|
t.Errorf("settled %s, want -808.5599", got)
|
||||||
|
}
|
||||||
|
for name, gross := range map[string]string{
|
||||||
|
"one cent out": "-808,5699",
|
||||||
|
"factor of ten": "-8.085,599",
|
||||||
|
"a euro out": "-809,5599",
|
||||||
|
"wrong instrument": "-908,5599",
|
||||||
|
} {
|
||||||
|
file, err := ReadCSV(strings.NewReader(scalableHeader + strings.Replace(row, ";-808,5599;", ";"+gross+";", 1) + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := ParseScalableCSV(file, brokerAccount(), nil); err == nil {
|
||||||
|
t.Errorf("%s: accepted a gross its own shares times price does not support", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A reinvested distribution settles shares times price, so it carries as many
|
// A reinvested distribution settles shares times price, so it carries as many
|
||||||
// decimal places as the two together need. A real export reinvests to nine,
|
// decimal places as the two together need. A real export reinvests to nine,
|
||||||
// which is past what money holds and past what a share count holds, so reading
|
// which is past what money holds and past what a share count holds, so reading
|
||||||
|
|||||||
+49
-27
@@ -639,26 +639,21 @@ func validateInvestment(f Facts, a Account, instruments map[string]Instrument) e
|
|||||||
return fmt.Errorf("%s requires a nonzero quantity", inv.Event)
|
return fmt.Errorf("%s requires a nonzero quantity", inv.Event)
|
||||||
}
|
}
|
||||||
// A position-only valuation carries the sign of the position change; a
|
// A position-only valuation carries the sign of the position change; a
|
||||||
// settled trade carries the sign of the cash, which is the opposite.
|
// settled trade carries the sign of the cash, which is the opposite. The
|
||||||
expected, ok := RoundedProduct(quantity, price)
|
// product is kept exact at 1e-16 so the comparison never rounds first.
|
||||||
if !ok {
|
product := new(big.Int).Mul(big.NewInt(quantity), big.NewInt(price))
|
||||||
return fmt.Errorf("%s quantity times price is out of range", inv.Event)
|
|
||||||
}
|
|
||||||
if inv.Settling() {
|
if inv.Settling() {
|
||||||
expected = -expected
|
product.Neg(product)
|
||||||
}
|
}
|
||||||
// The gross is checked to the precision the broker stated it at, and no
|
difference := new(big.Int).Sub(product, new(big.Int).Mul(big.NewInt(gross), productPerMoney))
|
||||||
// further. One broker prints the exact product to nine places, and the
|
if difference.Abs(difference).Cmp(grossSlack(gross, inv.Gross)) > 0 {
|
||||||
// check is then exact. Another prints the notional rounded to cents, where
|
expected, ok := RoundedProduct(quantity, price)
|
||||||
// demanding exactness rejects every trade whose product does not land on a
|
if !ok {
|
||||||
// whole cent - measured on a real export, 29 of 59 of them. One unit of
|
return fmt.Errorf("%s quantity times price is out of range", inv.Event)
|
||||||
// the stated precision is still four orders of magnitude tighter than the
|
}
|
||||||
// misplaced decimal separator this check exists to catch.
|
if inv.Settling() {
|
||||||
difference := expected - gross
|
expected = -expected
|
||||||
if difference < 0 {
|
}
|
||||||
difference = -difference
|
|
||||||
}
|
|
||||||
if difference >= statedUnit(inv.Gross) {
|
|
||||||
return fmt.Errorf("%s gross %s does not equal quantity %s times price %s, which is %s", inv.Event, inv.Gross.String(), inv.Quantity.String(), inv.Price.String(), Money(formatScaled(expected, moneyScale, 2)))
|
return fmt.Errorf("%s gross %s does not equal quantity %s times price %s, which is %s", inv.Event, inv.Gross.String(), inv.Quantity.String(), inv.Price.String(), Money(formatScaled(expected, moneyScale, 2)))
|
||||||
}
|
}
|
||||||
if inv.PositionOnly() {
|
if inv.PositionOnly() {
|
||||||
@@ -689,14 +684,41 @@ func settles(inv *Investment, gross, fee, tax, amount int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// statedUnit is one unit of the last decimal place a money figure was written
|
// productPerMoney converts money's ten-thousandths to the 1e-16 units a
|
||||||
// with, in exact ten-thousandths. Money always renders at least two places, so
|
// quantity times a price lands in.
|
||||||
// a whole-euro figure counts as stated to the cent.
|
var productPerMoney = new(big.Int).Exp(big.NewInt(10), big.NewInt(productScale-moneyScale), nil)
|
||||||
func statedUnit(m Money) int64 {
|
|
||||||
_, fraction, _ := strings.Cut(string(m), ".")
|
const productScale = quantityScale * 2
|
||||||
unit := int64(1)
|
|
||||||
for range moneyScale - len(strings.TrimRight(fraction, "0")) {
|
// grossSlack is how far a printed gross may sit from the product of the printed
|
||||||
unit *= 10
|
// quantity and price before the row is refused. Both ends are rounded, and
|
||||||
|
// neither states by how much.
|
||||||
|
//
|
||||||
|
// The gross is rounded to its own last decimal place: one broker prints the
|
||||||
|
// notional to the cent, so 0.426581 shares at 63.06 settle as 26.90 where the
|
||||||
|
// product is 26.90019786, and demanding exactness there rejects half a
|
||||||
|
// portfolio. The price is rounded to a precision the file does not state: the
|
||||||
|
// same export settles six NVIDIA shares at 808.5599 while printing the price
|
||||||
|
// as 134.76, whose product is 808.56, because the real fill was 134.759983.
|
||||||
|
// So the slack is half a unit of the gross's stated precision, plus one part
|
||||||
|
// in a hundred thousand of the gross itself.
|
||||||
|
//
|
||||||
|
// Measured over a complete real export of 88 security rows, exactly one
|
||||||
|
// deviates at all, by one part in eight million - eighty times inside this
|
||||||
|
// bound. What it refuses: any deviation above one part in a hundred thousand,
|
||||||
|
// which covers a price taken from the wrong share class and the lost decimal
|
||||||
|
// separator this check exists for, four orders of magnitude out. What it
|
||||||
|
// accepts: the broker's own rounding. On a gross stated to the cent the slack
|
||||||
|
// reaches a whole cent at around five hundred euro, above which a genuine
|
||||||
|
// one-cent error is indistinguishable from that rounding and is allowed.
|
||||||
|
func grossSlack(gross int64, printed Money) *big.Int {
|
||||||
|
_, fraction, _ := strings.Cut(string(printed), ".")
|
||||||
|
places := len(fraction)
|
||||||
|
if places > moneyScale {
|
||||||
|
places = moneyScale
|
||||||
}
|
}
|
||||||
return unit
|
half := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(productScale-places)), nil)
|
||||||
|
half.Quo(half, big.NewInt(2))
|
||||||
|
relative := new(big.Int).Abs(new(big.Int).Mul(big.NewInt(gross), productPerMoney))
|
||||||
|
return half.Add(half, relative.Quo(relative, big.NewInt(100_000)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
|
|||||||
respond(w, v, e)
|
respond(w, v, e)
|
||||||
})
|
})
|
||||||
s.mux.HandleFunc("POST /api/reclassify/preview", s.preview)
|
s.mux.HandleFunc("POST /api/reclassify/preview", s.preview)
|
||||||
|
s.mux.HandleFunc("POST /api/reclassify/progress", s.previewProgress)
|
||||||
s.mux.HandleFunc("POST /api/reclassify/apply", s.apply)
|
s.mux.HandleFunc("POST /api/reclassify/apply", s.apply)
|
||||||
s.mux.HandleFunc("POST /api/reclassify/cancel", s.cancel)
|
s.mux.HandleFunc("POST /api/reclassify/cancel", s.cancel)
|
||||||
s.mux.HandleFunc("POST /api/taxonomy/propose", s.taxonomyPropose)
|
s.mux.HandleFunc("POST /api/taxonomy/propose", s.taxonomyPropose)
|
||||||
@@ -490,7 +491,17 @@ func (s *Server) preview(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decode(w, r, &b) {
|
if !decode(w, r, &b) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v, e := s.app.Preview(r.Context(), b)
|
v, e := s.app.StartPreview(r.Context(), b)
|
||||||
|
respond(w, v, e)
|
||||||
|
}
|
||||||
|
func (s *Server) previewProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if !decode(w, r, &b) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := s.app.PreviewProgress(b.ID)
|
||||||
respond(w, v, e)
|
respond(w, v, e)
|
||||||
}
|
}
|
||||||
func (s *Server) apply(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) apply(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+174
-53
@@ -1,6 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react";
|
import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react";
|
||||||
import type { Dataset, Enrichment, Preview, State } from "./api";
|
import type {
|
||||||
|
Dataset,
|
||||||
|
Enrichment,
|
||||||
|
Preview,
|
||||||
|
PreviewProgress,
|
||||||
|
State,
|
||||||
|
} from "./api";
|
||||||
import { categoryPath, request } from "./api";
|
import { categoryPath, request } from "./api";
|
||||||
import { DateField, Empty, ErrorMessage, Field, Modal } from "./ui";
|
import { DateField, Empty, ErrorMessage, Field, Modal } from "./ui";
|
||||||
export function Classification({
|
export function Classification({
|
||||||
@@ -24,17 +30,96 @@ export function Classification({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [confirm, setConfirm] = useState(false);
|
const [confirm, setConfirm] = useState(false);
|
||||||
const cancel = async () => {
|
const [running, setRunning] = useState<PreviewProgress | null>(null);
|
||||||
if (!preview) return;
|
const runStart = useRef({ time: 0, analysed: 0 });
|
||||||
|
const finalize = (result: Preview) => {
|
||||||
|
result.changes ??= [];
|
||||||
|
result.errors ??= [];
|
||||||
|
result.new_merchants ??= [];
|
||||||
|
for (const change of result.changes) {
|
||||||
|
change.before.tag_ids ??= [];
|
||||||
|
change.after.tag_ids ??= [];
|
||||||
|
}
|
||||||
|
const confidenceRank: Record<string, number> = {
|
||||||
|
low: 0,
|
||||||
|
medium: 1,
|
||||||
|
high: 2,
|
||||||
|
};
|
||||||
|
result.changes.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(confidenceRank[a.after.classification.confidence || "low"] ?? 0) -
|
||||||
|
(confidenceRank[b.after.classification.confidence || "low"] ?? 0),
|
||||||
|
);
|
||||||
|
setPreview(result);
|
||||||
|
setSelected(
|
||||||
|
result.changes
|
||||||
|
.filter((change) => change.after.classification.confidence !== "low")
|
||||||
|
.map((change) => change.id),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// A run keeps going on the server while this page is closed; re-attach to
|
||||||
|
// it on mount instead of presenting a fresh, contradictory setup form.
|
||||||
|
useEffect(() => {
|
||||||
|
let stale = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const p = await request<PreviewProgress>("/api/reclassify/progress", {
|
||||||
|
id: "",
|
||||||
|
});
|
||||||
|
if (stale) return;
|
||||||
|
if (!p.done) {
|
||||||
|
runStart.current = { time: Date.now(), analysed: p.analysed };
|
||||||
|
setRunning(p);
|
||||||
|
} else if (
|
||||||
|
!p.error &&
|
||||||
|
p.preview &&
|
||||||
|
p.preview.revision === state.revision
|
||||||
|
) {
|
||||||
|
finalize(p.preview);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No run to re-attach to.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running || running.done) return;
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const p = await request<PreviewProgress>("/api/reclassify/progress", {
|
||||||
|
id: running.id,
|
||||||
|
});
|
||||||
|
p.errors ??= [];
|
||||||
|
if (!p.done) {
|
||||||
|
setRunning(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRunning(null);
|
||||||
|
if (p.error) setError(p.error);
|
||||||
|
else if (p.preview) finalize(p.preview);
|
||||||
|
} catch (err) {
|
||||||
|
setRunning(null);
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [running]);
|
||||||
|
const cancel = async (id: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await request<{ ok: boolean }>(
|
const response = await request<{ ok: boolean }>(
|
||||||
"/api/reclassify/cancel",
|
"/api/reclassify/cancel",
|
||||||
{ id: preview.id },
|
{ id },
|
||||||
);
|
);
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error("The server did not confirm cancellation.");
|
throw new Error("The server did not confirm cancellation.");
|
||||||
|
setRunning(null);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -77,7 +162,65 @@ export function Classification({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!preview ? (
|
{running ? (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Classifying transactions…</h3>
|
||||||
|
<p>
|
||||||
|
{running.analysed} of {running.total} analysed ·{" "}
|
||||||
|
{running.changes} proposed changes · {running.unchanged}{" "}
|
||||||
|
unchanged · {running.errors.length} errors
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-body">
|
||||||
|
<div
|
||||||
|
className="progress-track"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={running.total}
|
||||||
|
aria-valuenow={running.analysed}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: running.total
|
||||||
|
? `${Math.round((running.analysed / running.total) * 100)}%`
|
||||||
|
: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p role="status" className="muted">
|
||||||
|
Provider requests are spaced several seconds apart to respect rate
|
||||||
|
limits
|
||||||
|
{remainingEstimate(running, runStart.current)}. You can leave this
|
||||||
|
page; the preview keeps building and will be here when you return.
|
||||||
|
</p>
|
||||||
|
{running.errors.length > 0 && (
|
||||||
|
<div className="alert error">
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{running.errors.length} transaction
|
||||||
|
{running.errors.length === 1 ? "" : "s"} failed so far
|
||||||
|
</strong>
|
||||||
|
<p>{running.errors[running.errors.length - 1].error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => cancel(running.id)}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : !preview ? (
|
||||||
<section className="panel classification-setup">
|
<section className="panel classification-setup">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -101,7 +244,7 @@ export function Classification({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await request<Preview>(
|
const start = await request<PreviewProgress>(
|
||||||
"/api/reclassify/preview",
|
"/api/reclassify/preview",
|
||||||
{
|
{
|
||||||
revision: state.revision,
|
revision: state.revision,
|
||||||
@@ -111,46 +254,13 @@ export function Classification({
|
|||||||
fields,
|
fields,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (
|
if (!start.id)
|
||||||
!result.id ||
|
|
||||||
!result.revision ||
|
|
||||||
!("changes" in result) ||
|
|
||||||
!("errors" in result) ||
|
|
||||||
!("new_merchants" in result)
|
|
||||||
)
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"The server returned an incompatible preview.",
|
"The server returned an incompatible preview run.",
|
||||||
);
|
);
|
||||||
result.changes ??= [];
|
start.errors ??= [];
|
||||||
result.errors ??= [];
|
runStart.current = { time: Date.now(), analysed: 0 };
|
||||||
result.new_merchants ??= [];
|
setRunning(start);
|
||||||
for (const change of result.changes) {
|
|
||||||
change.before.tag_ids ??= [];
|
|
||||||
change.after.tag_ids ??= [];
|
|
||||||
}
|
|
||||||
const confidenceRank: Record<string, number> = {
|
|
||||||
low: 0,
|
|
||||||
medium: 1,
|
|
||||||
high: 2,
|
|
||||||
};
|
|
||||||
result.changes.sort(
|
|
||||||
(a, b) =>
|
|
||||||
(confidenceRank[
|
|
||||||
a.after.classification.confidence || "low"
|
|
||||||
] ?? 0) -
|
|
||||||
(confidenceRank[
|
|
||||||
b.after.classification.confidence || "low"
|
|
||||||
] ?? 0),
|
|
||||||
);
|
|
||||||
setPreview(result);
|
|
||||||
setSelected(
|
|
||||||
result.changes
|
|
||||||
.filter(
|
|
||||||
(change) =>
|
|
||||||
change.after.classification.confidence !== "low",
|
|
||||||
)
|
|
||||||
.map((change) => change.id),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -223,14 +333,8 @@ export function Classification({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Sparkles size={17} />
|
<Sparkles size={17} />
|
||||||
{busy ? "Classifying transactions…" : "Generate preview"}
|
{busy ? "Starting…" : "Generate preview"}
|
||||||
</button>
|
</button>
|
||||||
{busy && (
|
|
||||||
<p role="status" className="muted">
|
|
||||||
This can take a while for a large date range. Keep this page
|
|
||||||
open.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!state.data.transactions.length && (
|
{!state.data.transactions.length && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Import transactions from Accounts before generating a preview.
|
Import transactions from Accounts before generating a preview.
|
||||||
@@ -338,7 +442,7 @@ export function Classification({
|
|||||||
<button
|
<button
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={cancel}
|
onClick={() => cancel(preview.id)}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
{busy ? "Working…" : "Cancel preview"}
|
{busy ? "Working…" : "Cancel preview"}
|
||||||
@@ -474,3 +578,20 @@ function EnrichmentView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// remainingEstimate projects the finish time from the pace observed since
|
||||||
|
// this page attached to the run; the server paces provider requests, so the
|
||||||
|
// first sample is meaningless and re-attaching mid-run must not count work
|
||||||
|
// done before it.
|
||||||
|
function remainingEstimate(
|
||||||
|
p: PreviewProgress,
|
||||||
|
start: { time: number; analysed: number },
|
||||||
|
): string {
|
||||||
|
const sampled = p.analysed - start.analysed;
|
||||||
|
const remaining = p.total - p.analysed;
|
||||||
|
if (remaining <= 0 || sampled < 2 || !start.time) return "";
|
||||||
|
const seconds = Math.round(
|
||||||
|
((Date.now() - start.time) / sampled / 1000) * remaining,
|
||||||
|
);
|
||||||
|
if (seconds < 90) return ` — roughly ${seconds} seconds remaining`;
|
||||||
|
return ` — roughly ${Math.round(seconds / 60)} minutes remaining`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -200,6 +200,20 @@ export interface Preview {
|
|||||||
unchanged: number;
|
unchanged: number;
|
||||||
errors: { id: string; error: string }[];
|
errors: { id: string; error: string }[];
|
||||||
}
|
}
|
||||||
|
// PreviewProgress is the live state of a background classification run.
|
||||||
|
// Errors accumulate as they happen; preview is present only when done
|
||||||
|
// without a fatal error.
|
||||||
|
export interface PreviewProgress {
|
||||||
|
id: string;
|
||||||
|
total: number;
|
||||||
|
analysed: number;
|
||||||
|
changes: number;
|
||||||
|
unchanged: number;
|
||||||
|
errors: { id: string; error: string }[];
|
||||||
|
done: boolean;
|
||||||
|
error?: string;
|
||||||
|
preview?: Preview;
|
||||||
|
}
|
||||||
export interface ProposedCategory {
|
export interface ProposedCategory {
|
||||||
name: string;
|
name: string;
|
||||||
parent?: string;
|
parent?: string;
|
||||||
@@ -395,6 +409,7 @@ export function normalizeState(state: State): State {
|
|||||||
else if (!Array.isArray(state.data[key]))
|
else if (!Array.isArray(state.data[key]))
|
||||||
throw new Error(`The server state has invalid ${key}.`);
|
throw new Error(`The server state has invalid ${key}.`);
|
||||||
}
|
}
|
||||||
|
state.settings.private_names ??= [];
|
||||||
for (const tx of state.data.transactions) tx.enrichment.tag_ids ??= [];
|
for (const tx of state.data.transactions) tx.enrichment.tag_ids ??= [];
|
||||||
for (const merchant of state.data.merchants) {
|
for (const merchant of state.data.merchants) {
|
||||||
merchant.aliases ??= [];
|
merchant.aliases ??= [];
|
||||||
|
|||||||
@@ -1389,6 +1389,18 @@ summary .badge {
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.progress-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e1e9e5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--emerald);
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
footer {
|
footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user