Compare commits

..
14 Commits
Author SHA1 Message Date
Lars Nolden d787150f2d Add atomic bulk transaction editing with opt-in field changes 2026-09-19 13:01:43 +02:00
Lars Nolden 9cc3130b4f Add persistent multi-tag include and exclude filters 2026-09-19 12:44:05 +02:00
Lars Nolden 8aab21fe9e Clarify category creation flows 2026-09-14 15:44:20 +02:00
Lars Nolden 71e95917da Add account balance anchors 2026-09-14 13:32:18 +02:00
Lars Nolden 83bb86bc93 Wrap the transactions toolbar on phones
With the classification select beside the review toggle, the search
shrank to a sliver at phone widths; below 680px it now takes its own
full-width row.
2026-09-14 12:42:23 +02:00
Lars Nolden 0fd3c5c0dc Filter transactions by classification status
The Transactions toolbar gains a status filter over classification
provenance — manual, AI, merchant rule, transfer match, unclassified —
alongside the existing Needs review toggle. The Source column now
renders the same human labels the filter options use instead of raw
provenance keys, so "openrouter" reads as AI and both fallback shapes
read as Unclassified.
2026-09-14 12:39:11 +02:00
Lars Nolden 16daa01647 Document server-side name cap and hidden-rune rejection 2026-09-14 12:31:03 +02:00
Lars Nolden b7e5bf26cc Enforce name limits server-side and reject hidden runes in model names
Security review follow-ups. The 200-character registry-name cap the UI
forms promise now holds in domain.Validate for categories, tags,
merchants and instruments, so a non-browser client cannot persist an
unbounded name that every subsequent state response would carry. And a
model-supplied merchant or taxonomy name containing control or format
code points — bidi overrides, zero-width characters — is dropped like
an identifier-shaped one: React escaping already prevented injection,
but such names could visually spoof or reorder the review UI the
operator approves from.
2026-09-14 12:30:13 +02:00
Lars Nolden 676065292e Harden quick-add against review findings
Independent review of the quick-add range surfaced real holes:

- The emptyLabel guard suppressed creation for any name that happened
  to be a substring of the label — typing "Rent" in the parent picker
  (a substring of "No parent (root)") silently offered nothing. The
  guard is gone; the exact-match rule already suppresses creates when
  the full label is typed.
- Async creates resolved against click-time snapshots, so a checkbox
  toggled or chip removed during the server round trip was silently
  reverted. Consumers now apply functional updates or a latest-value
  ref.
- Created names are capped at 200 characters, matching the registry
  forms; over-long text fails inline instead of minting a permanent
  multi-kilobyte name.
- A create failing after the user blurred mid-flight reopens the list
  so the error is never invisible, and option rows are locked while a
  create is in flight so a race cannot override an explicit pick.
2026-09-14 12:20:33 +02:00
Lars Nolden c569ae7dbf Keep the armed combobox row visible while scrolling
The dropdown caps at 264px and a category registry easily exceeds it;
arrow navigation now scrolls the armed row into view with
block: nearest so the list follows the keyboard in both directions.
2026-09-14 12:05:16 +02:00
Lars Nolden 46cf578779 Arrow-key navigation for the combobox
Arrow keys cycle through the listed matches and create rows with
aria-activedescendant tracking, and Enter activates the armed row.
This closes a keyboard-only gap in quick-add: with matches still
listed, Enter deliberately refuses to mint from a half-typed name,
which left the create row reachable only by mouse.
2026-09-14 12:03:54 +02:00
Lars Nolden 671cbb8ef3 Give quick-add rows their listbox semantics
Create rows inside the combobox dropdown now carry role="option" like
their sibling matches, and a failed creation announces itself with
role="alert" instead of a silent list item.
2026-09-14 12:00:32 +02:00
Lars Nolden 1b3d7b22bb Create categories and tags in place from every assignment picker
Category and tag inputs across the transaction editor, Analyse
corrections, and merchant defaults now mint missing entries without a
detour through the registry pages. A bare name lands under the kind's
root, "Parent / Name" targets that parent, and typing an existing name
selects it instead of duplicating. Enter only creates when nothing
matches, server rejections surface inline in the dropdown, and
assignment pickers offer leaf categories only — the shape the server
validates.

Mutations now return the accepted state so callers can select the id
the server just minted, and the revision-keyed remounts on Transactions
and the registry pages are gone: they closed the open modal and threw
away pending edits the moment any in-modal creation committed.
2026-09-14 11:50:50 +02:00
Lars Nolden f9e829e6ba fix refresh 2026-09-14 10:01:57 +02:00
30 changed files with 2570 additions and 398 deletions
+36 -6
View File
@@ -13,7 +13,12 @@ from Accounts and confirm the reviewed mapping. The application starts empty
except for expense/income fallback categories. Create your category tree, tags,
and merchants in the UI. Enable a merchant's default rule explicitly only when
its category/tags are reliable; leave it disabled for ambiguous merchants such
as Amazon.
as Amazon. Category and tag pickers create in place: type an unknown name in
a category picker and choose "Create … in …" (a bare name lands under the
kind's root; "Parent / Name" targets that parent), or type a new tag next to
the tag checkboxes. Assignment pickers offer leaf categories only, matching
what the server accepts; a name that already exists is selected, never
duplicated.
Tests: go test ./...
The Go build embeds web/dist, so build React first. CGO and a C++ linker are
@@ -167,7 +172,14 @@ response records high, medium or low confidence. Imports never auto-apply a
low-confidence category: the row keeps the kind-specific unclassified
category with merchant and confidence recorded. Analyse previews show the
low-confidence suggestion unselected for review. Transactions exposes a
Needs review filter for low-confidence or fallback rows.
Needs review filter for low-confidence or fallback rows, and a
classification filter over how each row was classified: manually, by AI,
by a merchant rule, by transfer matching, or not at all. A model-proposed
merchant name is dropped (the row keeps its validated category and tags)
when it is identifier-shaped, longer than 100 characters, or contains
control or format code points such as bidirectional overrides and
zero-width characters, which could visually spoof the review UI; proposed
taxonomy names are rejected under the same hidden-rune rule.
Categories and tags have editable hints. Categories -> Propose taxonomy sends
up to 300 grouped, redacted transaction samples, then shows proposed
@@ -637,7 +649,20 @@ exists to be compared with the figures a bank or broker shows on its own screen.
A cash balance equals the real balance only when the journal holds that
account's complete history. A broker export does; a date-windowed bank statement
does not.
does not. A connected cash account closes that gap with a balance anchor: after
its first successful sync, the bank's booked (CLBD) balance is captured once,
verbatim, with the day it was true, and stored on the account (anchor_balance,
anchor_date in accounts.finance). The start balance - the money from before the
recorded rows - is derived as the anchor less every movement booked through the
anchor day, and reads as the first line of the account's flow breakdown. Because
the bank's figure is stored rather than the derivation, importing older history
later corrects the start balance by itself. An available or expected balance is
never anchored: it includes pending amounts with no booked fact to subtract. The
anchor is set once and never moved by later syncs; clear it in the account's
edit form and the next successful sync captures a fresh one. Running-balance
checks are only judged after the anchor day, where the balance is observable.
Anchors are refused on investment accounts, whose broker exports carry their
complete history.
Checks that fail mean the journal disagrees with itself: row arithmetic, cash
never negative, holdings never negative. A negative holding means a position was
@@ -696,7 +721,9 @@ The grammar is version-one strict: extension/split fields are not accepted yet.
Future format extensions require an explicit parser migration.
Stable category IDs survive renaming and moving; assigned categories must remain
leaves. Built-in roots and fallback leaves are protected. Move assigned records
leaves. Registry display names (category, tag, merchant, instrument) are
capped at 200 characters server-side, matching every UI form. Built-in roots
and fallback leaves are protected. Move assigned records
to another leaf before adding children to their former category. Category
merges migrate referenced transactions/defaults; tag merges deduplicate links;
tag deletion removes all affected links after UI confirmation. Merchant merging
@@ -739,6 +766,8 @@ 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.
Starting analysis reads the latest journal, independent of the page's revision.
The page refreshes registry labels before starting; analysis itself writes nothing.
History precedent sent with each request marks the user's own decisions
(manual edits and merchant rules) as source user, ranks them ahead of the
model's earlier answers, and reserves window slots for them, so one manual
@@ -746,8 +775,9 @@ correction outweighs repeated uncorrected AI output for the same payee.
Manually linking a merchant also records the counterparty as an alias, so
recurring payees classify locally without any provider request.
The finished run is a read-only preview. Apply all/selected writes all
selected changes in one canonical commit; financial facts never change. A
manual edit, external journal change or taxonomy change invalidates old previews.
selected changes in one canonical commit; financial facts never change. Apply
checks selected transactions against the preview snapshot; unrelated journal
commits do not require another analysis.
Previews are kept in memory for up to 24 hours from the start of analysis and
disappear on restart. Cancel writes nothing. Transfers and broker facts are
skipped, and unselected fields are preserved.
+33 -1
View File
@@ -144,6 +144,8 @@ Finance Duck verifies the callback state, exchanges the returned code for a `ses
Initial synchronization requests the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Automatic synchronization then runs **twice a day**, every **12 hours** after the last successful run, overlapping each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization at any time. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV.
**The start balance is anchored, not guessed.** Open banking shares a date-windowed history, so the sum of the recorded rows alone is not the account's real balance — the money from before the window is missing. After a connected cash account's first successful sync, Finance Duck captures the bank's **booked balance** once, with the day it was true, and stores it on the account (`anchor_balance`, `anchor_date`). **Wealth** then derives the start balance — the anchor less every movement booked through the anchor day — shows it as the first line of the account's flow breakdown, and reports the real balance. Only the booked (CLBD) figure is used, never an available balance that includes pending amounts. The anchor is set once and never moved by a later sync; importing older history corrects the derived start balance by itself, and clearing the anchor in the account's edit form makes the next sync capture a fresh one.
**HTTP 429 is a provider rate limit, not evidence that bank consent has expired.** Bank reads honor `Retry-After` and use bounded exponential retries. A longer or exhausted limit pauses further requests until the reported retry time; failed accounts keep their previous sync cursors and imported data. Session checks use the saved account metadata rather than fetching every account's details again. A failed session is reported once instead of also marking each of its accounts unavailable. After the cooldown, **Sync now** can retry; the warning clears after a successful sync. One-time authorization and code-exchange requests are never automatically replayed.
**A rate-limited sync is a wait, not a fault.** While every failing bank has supplied a retry time, the dashboard reports that synchronization retries by itself after that moment, the account card shows a rate-limit badge instead of a connection error, and the background scheduler sleeps until the deadline rather than retrying hourly into a refusal it already knows about. **Sync now** still tries immediately. Any failure without a supplied deadline keeps the hourly retry, and its cause is named where Finance Duck can determine it locally: an expired consent, an HTTP status, an unreachable provider, or a response it cannot use, such as a booked transaction without a booking date. Provider response text is never displayed.
@@ -423,7 +425,7 @@ Bank synchronization and recognized N26, ING, and Kontist CSV imports do **not**
**Classify newly imported transactions with AI** under **Classification preferences** controls whether importing contacts the provider at all. It covers CSV imports and bank synchronization, is on by default, and is stored as `classify_on_import` in `config.toml`. With it off, no import makes a provider request: enabled merchant rules still classify, and everything else arrives unclassified and editable without a failure that would suggest the provider was unreachable. **AI classification → Analyse** still works on demand, so you can review a batch deliberately instead of on every import.
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both.
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both. Transactions also filters by classification status — manual, AI, merchant rule, transfer match, or unclassified — matching the labels its Source column shows.
Classification choices retain their names, paths, hints, and aliases, but use short request-local references such as `c1`, `m1`, and `t1` instead of long database IDs. Merchant defaults and applicable classification history use the same references. Every eligible category, merchant, and tag remains available; responses are mapped back to canonical IDs and validated locally.
@@ -439,6 +441,36 @@ The classifier learns from you in three ways. Manually linking a merchant record
**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.
Wherever a category or tag is assigned — the transaction editor, an **Analyse** correction, or a merchant's defaults — the picker creates missing entries in place. Type a name and choose **Create "…" in …**: a bare name lands under the kind's root, and **Parent / Name** creates under that parent. New tags are typed next to the tag checkboxes. Assignment pickers offer leaf categories only, matching what the server accepts, and an existing name is selected rather than duplicated. Creating during an **Analyse** review keeps the preview applicable as long as the transactions themselves are unchanged.
## Filter by tags
**Overview** and **Transactions** share **Include tags** and **Exclude tags** pickers. Search for a tag and select it to add a removable pill; both pickers accept multiple tags.
- **Include tags** matches transactions carrying **any** selected tag. Leave it empty to include tagged and untagged transactions.
- **Exclude tags** hides transactions carrying **any** selected tag, including transactions that also carry an included tag.
- Both lists combine with the date, currency, account, category, and merchant filters. Adding a tag to one picker removes it from the other.
- Tag selections are remembered in this browser across visits. Remove an individual pill to clear it, or use **Reset** to clear all filters and restore the default six-month period.
For private spending, leave **Include tags** empty and add `business` to **Exclude tags**. Business-tagged expenses, including any taxes you tag that way, leave the overview's totals, charts, comparisons, and the transaction list. Untagged income remains included: net cash flow and income-based figures describe the filtered transactions, not your actual savings. Wealth and account balances remain unfiltered.
The dashboard API accepts repeated `tag_ids` and `exclude_tag_ids` query parameters, for example `?tag_ids=tag_holiday&tag_ids=tag_shared&exclude_tag_ids=tag_business`. Values are literal tag IDs, not comma-separated lists.
## Bulk edit transactions
In **Transactions**, choose **Bulk edit**, then select individual rows, the current page, or **Select all N matching** to include every currently filtered result across pages. Selection follows you across pages; changing a filter, search, review toggle, or classification status clears it. **Clear selection** unchecks the rows; **Cancel bulk edit** leaves selection mode.
**Edit selected** opens a change editor. Only explicitly chosen operations are applied:
- **Category** and **Merchant** start at **Leave unchanged**. A tag-only edit preserves each selected transaction's individual category and merchant, even when they differ.
- **Add tags** adds only the chosen tags, retaining existing ones. **Remove tags** removes only the chosen tags. Adding a tag already present or removing a known tag absent from a row does not disturb its other tags.
- Category changes require only expenses or only income and a compatible leaf category. Merchant changes allow expenses and income together, with a separate **Clear merchant** choice.
- Selections containing transfers or investments can change tags, but not category or merchant. Bank facts, transaction kinds, and transfer links are never edited.
Review the operation summary and selected count before applying. The entire batch is saved in one journal commit and marked manually classified. An invalid edit or revision conflict saves nothing and keeps the editor's choices; cancel and refresh the journal before retrying a revision conflict. Successful saves clear the selection, including when an edit makes rows disappear from the active filter.
The bulk API is `POST /api/transactions/bulk` with `revision`, `transaction_ids`, and only the requested fields: `category_id`, `merchant_id`, `add_tag_ids`, `remove_tag_ids`. Omitted category/merchant fields preserve per-transaction values; `merchant_id: ""` explicitly clears the merchant. Tags are additive/removal operations, not a replacement list.
## 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.
+18 -20
View File
@@ -15,13 +15,14 @@ import (
type Store struct{ db *sql.DB }
type Filter struct {
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
AccountID string `json:"account_id"`
CategoryID string `json:"category_id"`
TagID string `json:"tag_id"`
MerchantID string `json:"merchant_id"`
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
AccountID string `json:"account_id"`
CategoryID string `json:"category_id"`
TagIDs []string `json:"tag_ids"`
ExcludeTagIDs []string `json:"exclude_tag_ids"`
MerchantID string `json:"merchant_id"`
}
type Total struct {
@@ -272,21 +273,18 @@ func (f Filter) where() (string, []any) {
add("t.account_id = ?", f.AccountID)
add("t.merchant_id = ?", f.MerchantID)
add("EXISTS (SELECT 1 FROM category_ancestors ca WHERE ca.category_id = t.category_id AND ca.ancestor_id = ?)", f.CategoryID)
if f.TagID != "" {
ids := strings.Split(f.TagID, ",")
placeholders := make([]string, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id != "" {
placeholders = append(placeholders, "?")
args = append(args, id)
}
addTags := func(predicate string, ids []string) {
if len(ids) == 0 {
return
}
if len(placeholders) == 0 {
clauses = append(clauses, "FALSE")
} else {
clauses = append(clauses, "EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
placeholders := make([]string, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
clauses = append(clauses, predicate+" (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
}
addTags("EXISTS", f.TagIDs)
addTags("NOT EXISTS", f.ExcludeTagIDs)
return strings.Join(clauses, " AND "), args
}
+118 -3
View File
@@ -132,7 +132,7 @@ func TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"}
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: []string{"tag_shared", "tag_work", "tag_shared"}}
got := queryFixture(t, s, filter)
want := []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "0.0000", Net: "-900719925474.1000"}}
if !reflect.DeepEqual(got.Totals, want) {
@@ -141,17 +141,132 @@ func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
if len(got.Monthly) != 1 || got.Monthly[0].Count != 2 {
t.Fatalf("tag union count: %#v", got.Monthly)
}
filter.TagID = "tag_shared"
filter.TagIDs = []string{"tag_shared"}
got = queryFixture(t, s, filter)
if len(got.Totals) != 1 || got.Totals[0].Expenses != "900719925474.0991" {
t.Fatalf("single tag filter: %#v", got.Totals)
}
filter.TagID = "tag_shared') OR TRUE --"
filter.TagIDs = []string{"tag_shared') OR TRUE --"}
if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 {
t.Fatalf("tag input altered SQL predicate: %#v", totals)
}
}
func TestTagExclusionsAndComposition(t *testing.T) {
s := openFixture(t, fixture())
cases := []struct {
name string
include []string
exclude []string
totals []Total
count int64
tagIDs []string
}{
{
name: "excluded tag removes whole multi-tag transaction",
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "100.1235", Net: "100.1226"}},
count: 3,
tagIDs: []string{"tag_work"},
},
{
name: "any excluded tag removes transaction and untagged income survives",
exclude: []string{"tag_shared", "tag_work"},
totals: []Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.1235", Net: "100.1235"}},
count: 2,
tagIDs: []string{},
},
{
name: "include union and exclusion intersect with exclusion winning overlap",
include: []string{"tag_shared", "tag_work"},
exclude: []string{"tag_shared"},
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}},
count: 1,
tagIDs: []string{"tag_work"},
},
{
name: "identical include and exclude match nothing",
include: []string{"tag_shared"},
exclude: []string{"tag_shared"},
totals: []Total{},
tagIDs: []string{},
},
{
name: "exclusion values cannot alter SQL",
exclude: []string{"tag_shared') OR TRUE --"},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
{
name: "empty lists leave transactions unrestricted",
include: []string{},
exclude: []string{},
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
count: 4,
tagIDs: []string{"tag_shared", "tag_work"},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: tt.include, ExcludeTagIDs: tt.exclude})
if !reflect.DeepEqual(got.Totals, tt.totals) {
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.totals)
}
var count int64
for _, month := range got.Monthly {
count += month.Count
}
if count != tt.count {
t.Fatalf("transaction count: got %d, want %d", count, tt.count)
}
tagIDs := make([]string, 0, len(got.Tags))
for _, tag := range got.Tags {
tagIDs = append(tagIDs, tag.ID)
}
if !reflect.DeepEqual(tagIDs, tt.tagIDs) {
t.Fatalf("tag groups: got %#v, want %#v", got.Tags, tt.tagIDs)
}
accounts := []Group{}
if len(tt.totals) != 0 {
accounts = append(accounts, Group{ID: "acc_eur", Name: "Current", Currency: "EUR", Amount: tt.totals[0].Net, Count: tt.count})
}
if !reflect.DeepEqual(got.Accounts, accounts) {
t.Fatalf("account groups: got %#v, want %#v", got.Accounts, accounts)
}
})
}
}
func TestTagFiltersApplyToPreviousPeriodAndCategoryRollups(t *testing.T) {
data := fixture()
// Mirror current transactions into the preceding month, including the
// untagged income and the multi-tag expense that must be excluded.
for _, transaction := range data.Transactions[:4] {
transaction.Facts.ID += "_previous"
transaction.Facts.Fingerprint += "_previous"
transaction.Facts.BookingDate = "2026-01-15"
data.Transactions = append(data.Transactions, transaction)
}
s := openFixture(t, data)
got := queryFixture(t, s, Filter{
From: "2026-02-01", To: "2026-02-28", Currency: "EUR",
TagIDs: []string{"tag_shared", "tag_work"}, ExcludeTagIDs: []string{"tag_shared"},
})
want := []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}}
if !reflect.DeepEqual(got.Totals, want) || !reflect.DeepEqual(got.Previous, want) {
t.Fatalf("period totals: current %#v, previous %#v, want %#v", got.Totals, got.Previous, want)
}
groups := []Group{
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-0.0009", Count: 1},
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-0.0009", Count: 1},
}
if !reflect.DeepEqual(got.Categories, groups) || !reflect.DeepEqual(got.PreviousCategories, groups) {
t.Fatalf("category rollups: current %#v, previous %#v, want %#v", got.Categories, got.PreviousCategories, groups)
}
}
func TestAncestorFilteringAndRollups(t *testing.T) {
s := openFixture(t, fixture())
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", CategoryID: "cat_living"}
+112 -14
View File
@@ -60,6 +60,64 @@ func seed(t *testing.T, a *App, s State) State {
return result.State
}
func TestSaveAccountClearsStaleBalanceAnchorOnIdentityChange(t *testing.T) {
cases := []struct {
name string
change func(*domain.Account)
clear bool
}{
{
name: "currency",
change: func(account *domain.Account) {
account.Currency = "USD"
},
clear: true,
},
{
name: "external account",
change: func(account *domain.Account) {
account.ExternalAccountID = "new_uid"
},
clear: true,
},
{
name: "display name",
change: func(account *domain.Account) {
account.DisplayName = "Renamed"
},
clear: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a, s := testApp(t)
anchored := s.Data.Accounts[0]
anchored.ExternalAccountID = "old_uid"
anchored.AnchorBalance = "100.00"
anchored.AnchorDate = "2026-09-10"
var err error
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
return SaveAccount(d, anchored)
})
if err != nil {
t.Fatal(err)
}
changed := anchored
tc.change(&changed)
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
return SaveAccount(d, changed)
})
if err != nil {
t.Fatal(err)
}
got := s.Data.Accounts[0]
if tc.clear != (got.AnchorBalance == "" && got.AnchorDate == "") {
t.Fatalf("anchor after %s change: balance=%q date=%q", tc.name, got.AnchorBalance, got.AnchorDate)
}
})
}
}
// A released binary wrote include_amount into config.toml. Refusing it on
// startup made every upgraded deployment crash-loop against its own settings
// file, so a retired key must load and then disappear on the next save.
@@ -171,10 +229,8 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
defer cancel()
for _, model := range []string{"test/model", "test/another-model"} {
preview, err := runPreview(t, a, PreviewRequest{
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true},
})
preview, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -224,10 +280,8 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
start, err := a.StartPreview(context.Background(), PreviewRequest{
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true},
})
start, err := a.StartPreview(context.Background(), PreviewRequest{From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -319,7 +373,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
}
mockClassifier(t, a)
before := domain.Clone(s.Data)
preview, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -361,6 +415,50 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
}
}
func TestPreviewUsesLatestSnapshotWithoutClientRevision(t *testing.T) {
ctx := context.Background()
a, page := testApp(t)
page = seed(t, a, page)
mockClassifier(t, a)
request := PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}}
id := page.Data.Transactions[0].Facts.ID
// Another writer changes the journal after the page loaded its state.
current, err := a.Mutate(ctx, page.Revision, func(d *domain.Dataset) error {
for i := range d.Transactions {
if d.Transactions[i].Facts.ID == id {
d.Transactions[i].Enrichment.TagIDs = []string{"home"}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
preview, err := runPreview(t, a, request)
if err != nil {
t.Fatal(err)
}
if preview.Revision != current.Revision || len(preview.Changes) != 2 {
t.Fatalf("preview did not use the latest snapshot: %+v", preview)
}
unchanged, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(unchanged.Data, current.Data) {
t.Fatal("starting analysis changed the journal")
}
applied, err := a.ApplyPreview(ctx, preview.ID, preview.Revision, []string{id}, nil)
if err != nil {
t.Fatal(err)
}
for _, tx := range applied.Data.Transactions {
if tx.Facts.ID == id && (tx.Enrichment.CategoryID != "groceries" || !reflect.DeepEqual(tx.Enrichment.TagIDs, []string{"home"})) {
t.Fatalf("analysis overwrote an edit made after the page loaded: %+v", tx.Enrichment)
}
}
}
func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
for _, tc := range []struct {
name string
@@ -378,7 +476,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
mockClassifier(t, a)
p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -392,7 +490,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
if tc.newPreview {
// Completing another run performs expired-preview cleanup.
// An empty range needs no additional provider request.
if _, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil {
if _, err := runPreview(t, a, PreviewRequest{From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil {
t.Fatal(err)
}
}
@@ -423,7 +521,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
mockClassifier(t, a)
p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -455,7 +553,7 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
mockClassifier(t, a)
p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -508,7 +606,7 @@ func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
t.Fatal(err)
}
mockClassifier(t, a)
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}})
p, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}})
if err != nil {
t.Fatal(err)
}
+61
View File
@@ -882,6 +882,7 @@ func (a *App) Sync(ctx context.Context) (State, error) {
}
s = result.State
a.ops.AccountSync[account.ID] = now.Format(time.RFC3339)
s = a.anchorAccount(ctx, s, account, to)
}
a.ops.SyncError = strings.Join(failures, "; ")
a.ops.SyncRetryAt = ""
@@ -897,6 +898,66 @@ func (a *App) Sync(ctx context.Context) (State, error) {
return a.snapshot(ctx)
}
// anchorAccount fixes a connected cash account's start balance after its first
// successful sync: the bank's booked (CLBD) balance is captured once, verbatim,
// with the day it was true, so a date-windowed history still yields the real
// balance — the money from before the window is derived as the anchor less
// every movement booked through the anchor date, and an older import later
// corrects that derivation by itself. The balance is fetched after the
// transactions to minimize the gap between the two reads. Banks supply booking
// dates rather than exact times, so the anchor day is deliberately treated as
// one completed booked state. Every failure leaves the anchor unset for the
// next sync to retry; a missing CLBD figure is such a failure, because an
// available or expected balance includes pending amounts that have no booked
// fact to subtract.
func (a *App) anchorAccount(ctx context.Context, s State, account domain.Account, today string) State {
if account.Investing() || account.AnchorDate != "" || account.ExternalAccountID == "" {
return s
}
balances, err := a.bank.Balances(ctx, account.ExternalAccountID)
if err != nil {
return s
}
var selected banking.Balance
anchorDate := ""
for _, balance := range balances {
if balance.Type != "CLBD" || balance.Currency != account.Currency {
continue
}
date := balance.ReferenceDate
if date == "" {
date = today
} else if _, e := time.Parse("2006-01-02", date); e != nil || date > today {
continue
}
if date < anchorDate {
continue
}
// Two different booked figures for the same account, currency and
// reference day are ambiguous. Do not let response order decide money.
if date == anchorDate && anchorDate != "" && balance.Amount != selected.Amount {
return s
}
selected, anchorDate = balance, date
}
if anchorDate == "" {
return s
}
data := domain.Clone(s.Data)
for i := range data.Accounts {
if data.Accounts[i].ID != account.ID {
continue
}
data.Accounts[i].AnchorBalance = selected.Amount
data.Accounts[i].AnchorDate = anchorDate
if next, e := a.commit(ctx, s.Revision, data); e == nil {
return next
}
return s
}
return s
}
// syncInterval is how often connected accounts synchronize on their own. Twice
// a day halves how long a booking can sit unseen while staying inside Enable
// Banking's documented background allowance of roughly four fetches per day per
+6
View File
@@ -61,6 +61,12 @@ func SaveAccount(d *domain.Dataset, v domain.Account) error {
}
for i, x := range d.Accounts {
if x.ID == v.ID {
// A balance belongs to the account identity and currency that the
// bank reported. Changing either makes the captured figure stale;
// clear it so the next connected sync can capture a matching one.
if x.Currency != v.Currency || x.ExternalAccountID != v.ExternalAccountID {
v.AnchorBalance, v.AnchorDate = "", ""
}
d.Accounts[i] = v
return nil
}
+7 -7
View File
@@ -10,9 +10,9 @@ import (
"testing"
)
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
func checkOpenRouterPreview(t *testing.T, a *App, auth <-chan string, key string) {
t.Helper()
p, err := runPreview(t, a, 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{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -68,7 +68,7 @@ func TestOpenRouterKeyRotationChangesProviderAuthorization(t *testing.T) {
if strings.Contains(string(encoded), "private-key") {
t.Fatal("saved credential leaked into browser state")
}
checkOpenRouterPreview(t, a, s, auth, key)
checkOpenRouterPreview(t, a, auth, key)
}
}
@@ -101,7 +101,7 @@ func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testin
}
})
reopen()
checkOpenRouterPreview(t, a, s, auth, "environment-private-key")
checkOpenRouterPreview(t, a, auth, "environment-private-key")
for _, key := range []string{"saved-private-key", ""} {
var err error
s, err = a.SaveOpenRouterKey(context.Background(), key)
@@ -119,7 +119,7 @@ func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testin
if s.Status.AIConfigured != (key != "") {
t.Fatal("restarted credential status ignored saved preference")
}
checkOpenRouterPreview(t, a, s, auth, key)
checkOpenRouterPreview(t, a, auth, key)
}
}
@@ -187,7 +187,7 @@ func TestOpenRouterRejectedKeysPreserveActiveCredential(t *testing.T) {
}
})
}
checkOpenRouterPreview(t, a, s, auth, key)
checkOpenRouterPreview(t, a, auth, key)
}
func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
@@ -216,5 +216,5 @@ func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
t.Fatal("persistence error leaked credential content")
}
}
checkOpenRouterPreview(t, a, s, auth, "active-private-key")
checkOpenRouterPreview(t, a, auth, "active-private-key")
}
+9 -14
View File
@@ -21,11 +21,10 @@ type Fields struct {
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"`
From string `json:"from"`
To string `json:"to"`
Model string `json:"model"`
Fields Fields `json:"fields"`
}
type Change struct {
ID string `json:"id"`
@@ -119,12 +118,11 @@ func previewEligible(t domain.Transaction, r PreviewRequest) bool {
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.
// StartPreview takes a fresh journal snapshot and starts a read-only
// classification run. It does not require the page's revision: a sync or edit
// while the page is open must not block analysis. ApplyPreview checks for
// conflicting changes before writing. Only one run exists at a time; callers
// poll PreviewProgress instead of holding an HTTP request open.
func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) {
if err := validatePreviewRequest(r); err != nil {
return PreviewProgress{}, err
@@ -138,9 +136,6 @@ func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgre
if err != nil {
return PreviewProgress{}, err
}
if r.Revision != s.Revision {
return PreviewProgress{}, errors.New("revision conflict: reload before analysing")
}
client := a.classifier.WithModel(r.Model)
total := 0
for _, t := range s.Data.Transactions {
+73 -5
View File
@@ -17,8 +17,9 @@ import (
)
type bankScenario struct {
session banking.Session
fail bool
session banking.Session
fail bool
balances []banking.Balance
}
func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) {
@@ -41,6 +42,9 @@ func (b *bankScenario) Status(context.Context, string) (banking.SessionStatus, e
return status, nil
}
func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) {
if b.balances != nil {
return b.balances, nil
}
return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil
}
func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string, _ bool) ([]domain.Facts, error) {
@@ -83,6 +87,53 @@ func TestSyncRestoresSavedConsentBindingsAndDoesNotDuplicateFacts(t *testing.T)
t.Fatal("provider failure was not isolated from canonical data")
}
}
// The first successful sync fixes the start balance from the bank's booked
// figure only: an available balance includes pending amounts with no booked
// fact to subtract, and a later balance change must never move an anchor that
// has been set — the anchor is the day a figure was true, not a mirror.
func TestSyncAnchorsBalanceOnceFromBookedFigureOnly(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
account.ExternalAccountID = "provider_uid"
provider := &bankScenario{
session: banking.Session{ID: "session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}},
balances: []banking.Balance{{Amount: "999.99", Currency: "EUR", Type: "ITAV"}},
}
a.bank = provider
a.ops.Sessions = []banking.Session{provider.session}
if err := a.saveOps(); err != nil {
t.Fatal(err)
}
unbooked, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if got := unbooked.Data.Accounts[0]; got.AnchorBalance != "" || got.AnchorDate != "" {
t.Fatalf("available-only balance was anchored: %+v", got)
}
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
older := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
provider.balances = append(provider.balances,
banking.Balance{Amount: "240.00", Currency: "EUR", Type: "CLBD", ReferenceDate: older},
banking.Balance{Amount: "250.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday},
)
anchored, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if got := anchored.Data.Accounts[0]; got.AnchorBalance != "250.00" || got.AnchorDate != yesterday {
t.Fatalf("booked balance was not anchored at its reference day: %+v", got)
}
provider.balances = []banking.Balance{{Amount: "300.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday}}
retained, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(anchored.Data, retained.Data) {
t.Fatal("a later balance moved an existing anchor")
}
}
func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
@@ -196,9 +247,16 @@ func TestSyncSessionRateLimitPreservesBindingsAndRecovers(t *testing.T) {
failures: map[string]error{},
}
a.bank = b
first, err := a.Sync(ctx)
if err != nil || len(first.Data.Transactions) != 4 {
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(first.Data.Transactions), err, first.Status.SyncError)
}
// The first successful sync also anchors each account's balance; a second
// sync reaches the steady state where the session bindings have absorbed
// the anchored accounts and nothing changes any more.
before, err := a.Sync(ctx)
if err != nil || len(before.Data.Transactions) != 4 {
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(before.Data.Transactions), err, before.Status.SyncError)
if err != nil || !reflect.DeepEqual(first.Data, before.Data) {
t.Fatalf("steady-state sync changed canonical data: %v", err)
}
old := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
a.ops.LastSync = old
@@ -267,9 +325,19 @@ func TestSyncMissingMembershipStillRejectsAccount(t *testing.T) {
if len(b.accounts) != 1 || b.accounts[0].ID != "other" || a.ops.AccountSync[s.Data.Accounts[0].ID] != last || a.ops.LastSync != last {
t.Fatal("missing member was fetched or advanced its cursor, or valid member was skipped")
}
if !reflect.DeepEqual(before.Accounts, after.Data.Accounts) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
if !reflect.DeepEqual(before.Accounts[0], after.Data.Accounts[0]) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
t.Fatal("missing membership changed bindings or imported unauthorized facts")
}
// The authorized member's first successful sync anchors its balance from
// the bank's booked figure; the rejected member must not gain one.
anchored := after.Data.Accounts[1]
if anchored.AnchorBalance != "100.00" || anchored.AnchorDate == "" {
t.Fatalf("authorized member was not anchored: %+v", anchored)
}
anchored.AnchorBalance, anchored.AnchorDate = "", ""
if !reflect.DeepEqual(before.Accounts[1], anchored) {
t.Fatal("anchoring changed more than the anchor on the authorized member")
}
}
func TestSyncTransactionFailuresPreserveProgressAndSafeErrors(t *testing.T) {
+60 -8
View File
@@ -49,9 +49,11 @@ type WealthAccount struct {
Records int `json:"records"`
FirstBooking string `json:"first_booking,omitempty"`
LastBooking string `json:"last_booking,omitempty"`
// Cash is every recorded movement summed. It equals the account's real
// balance only when the journal holds that account's complete history,
// which a broker export does and a date-windowed bank statement does not.
// Cash is every recorded movement summed — plus, when the account carries a
// balance anchor, the derived start balance. Without an anchor it equals
// the account's real balance only when the journal holds that account's
// complete history, which a broker export does and a date-windowed bank
// statement does not.
Cash domain.Money `json:"cash"`
// Positions is the market value of every priced holding, and Wealth the two
// together: the number this page exists to show. Unpriced counts the
@@ -192,6 +194,13 @@ func WealthOf(data domain.Dataset) Wealth {
unappliedFee, unappliedTax int64
unappliedRows int
unmatchedCash, unmatchedRows int64
// anchored accounts carry the bank's booked balance on anchorDate.
// residual is that figure less every movement booked through the
// anchor day: the money from before the recorded history, and the
// account's derived start balance.
anchored bool
anchorDate string
residual int64
}
states := map[string]*accountState{}
state := func(id string) *accountState {
@@ -200,15 +209,42 @@ func WealthOf(data domain.Dataset) Wealth {
}
return states[id]
}
// An anchored account's balance is the bank's own figure plus what moved
// after the anchor day. The residue is order-independent, so it is settled
// before the chronological pass that judges running balances.
for _, account := range data.Accounts {
if account.AnchorDate == "" {
continue
}
anchor, err := account.AnchorBalance.Minor()
if err != nil {
continue
}
st := state(account.ID)
st.anchored, st.anchorDate, st.residual = true, account.AnchorDate, anchor
for _, t := range data.Transactions {
if t.Facts.AccountID != account.ID || t.Facts.BookingDate > account.AnchorDate {
continue
}
if minor, e := t.Facts.Amount.Minor(); e == nil {
st.residual -= minor
}
}
}
// A day's rows are applied together before any low-water mark is taken.
// Order within a day is not knowable: a broker export states a booking date
// and a clock time, the time is local and crosses midnight, so only the
// date is imported. A purchase funded by a sale nine seconds earlier then
// arrives in an arbitrary order, and checking row by row reports a dip
// that never happened.
// Days on or before an anchor are not judged at all: the history before
// the anchor is incomplete by definition, so a running balance there is
// not observable.
closeDay := func(st *accountState) {
if st.cash < st.lowestCash {
st.lowestCash, st.lowestCashDate = st.cash, st.day
if !st.anchored || st.day > st.anchorDate {
if effective := st.cash + st.residual; effective < st.lowestCash {
st.lowestCash, st.lowestCashDate = effective, st.day
}
}
for _, held := range st.holdings {
if held.units < held.lowest {
@@ -317,13 +353,22 @@ func WealthOf(data domain.Dataset) Wealth {
if kind == "" {
kind = domain.AccountCash
}
cash := st.cash + st.residual
entry := WealthAccount{
AccountID: account.ID, DisplayName: account.DisplayName, Institution: account.Institution,
Currency: account.Currency, Kind: kind, Active: account.Active,
Records: st.records, FirstBooking: st.first, LastBooking: st.last,
Cash: domain.FormatMoney(st.cash), Flows: []WealthFlow{},
Cash: domain.FormatMoney(cash), Flows: []WealthFlow{},
Holdings: []WealthHolding{}, Checks: []WealthCheck{},
}
// The start balance reads first, like the carried-over line on a paper
// statement, and keeps the invariant that the flows sum to the balance.
if st.anchored {
entry.Flows = append(entry.Flows, WealthFlow{
Event: "anchor", Label: "Start balance (before the recorded rows)",
Cash: domain.FormatMoney(st.residual),
})
}
for _, flow := range flowLabels {
if moved := st.flows[flow.event]; moved != nil {
entry.Flows = append(entry.Flows, WealthFlow{
@@ -333,7 +378,7 @@ func WealthOf(data domain.Dataset) Wealth {
}
}
seen(account.Currency)
totals[account.Currency] += st.cash
totals[account.Currency] += cash
positions, unpriced, stale := int64(0), 0, []string{}
for _, id := range st.order {
held := st.holdings[id]
@@ -372,7 +417,7 @@ func WealthOf(data domain.Dataset) Wealth {
}
slices.SortFunc(entry.Holdings, func(x, y WealthHolding) int { return strings.Compare(x.Name, y.Name) })
entry.Positions, entry.Unpriced = domain.FormatMoney(positions), unpriced
entry.Wealth = domain.FormatMoney(st.cash + positions)
entry.Wealth = domain.FormatMoney(cash + positions)
positionTotals[account.Currency] += positions
unpricedTotals[account.Currency] += unpriced
@@ -384,8 +429,15 @@ func WealthOf(data domain.Dataset) Wealth {
} else {
check("Row arithmetic", "every record agrees with its own gross, fee, tax, quantity and price", false)
}
if st.anchored {
check("Balance anchored", fmt.Sprintf("cash is the bank's own booked balance %s on %s plus every movement after that day; the start balance line, %s, is that figure less the movements booked through it", account.AnchorBalance, st.anchorDate, domain.FormatMoney(st.residual)), false)
} else if !account.Investing() && account.ExternalAccountID != "" {
check("Balance not anchored", "cash is the recorded movements only; the next successful synchronization captures the bank's booked balance and fixes the start balance", false)
}
if st.lowestCash < 0 {
check("Cash never negative", fmt.Sprintf("balance reached %s on %s, so the history is incomplete or a movement is misread", domain.FormatMoney(st.lowestCash), st.lowestCashDate), true)
} else if st.anchored {
check("Cash never negative", "the running balance stays at or above zero from the anchor day onward; earlier days are not judged against an incomplete window", false)
} else {
check("Cash never negative", "the running balance stays at or above zero throughout", false)
}
+68
View File
@@ -416,3 +416,71 @@ func TestWealthCountsHandValuedAssets(t *testing.T) {
t.Errorf("assets not echoed sorted by name with their dates: %+v", report.Assets)
}
}
// A bank's date-windowed history starts mid-life, so an anchored account
// derives its start balance: the bank's booked figure on the anchor day less
// everything booked through it. The derived line keeps the flows summing to
// the balance, and the pre-anchor window is never judged as an overdraft —
// the history there is incomplete by definition.
func TestAnchoredAccountDerivesStartBalance(t *testing.T) {
data := domain.NewDataset()
data.Accounts = []domain.Account{
{ID: "acc_anchored", DisplayName: "Checking", Currency: "EUR", Active: true, ExternalAccountID: "uid_one", AnchorBalance: "2450.00", AnchorDate: "2026-09-10"},
{ID: "acc_plain", DisplayName: "Connected", Currency: "EUR", Active: true, ExternalAccountID: "uid_two"},
}
row := func(id, account, date string, amount domain.Money) domain.Transaction {
f := domain.Facts{ID: id, Source: "enablebanking", AccountID: account, BookingDate: date, Amount: amount, Currency: "EUR", RawDescription: id, Fingerprint: "fp_" + id}
return domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)}
}
data.Transactions = []domain.Transaction{
// The recorded window alone would dip to 900 before the anchor day.
row("tx_pre", "acc_anchored", "2026-09-01", "-900.00"),
row("tx_on", "acc_anchored", "2026-09-10", "50.00"),
row("tx_post", "acc_anchored", "2026-09-12", "-100.00"),
row("tx_other", "acc_plain", "2026-09-12", "10.00"),
}
if err := domain.Validate(data); err != nil {
t.Fatal(err)
}
report := WealthOf(data)
anchored := report.Accounts[0]
// 2450.00 on 2026-09-10 less the 850.00 booked through that day puts
// 3300.00 before the window; the balance is 2450.00 100.00 booked after.
if anchored.Cash != "2350.00" || anchored.Wealth != "2350.00" {
t.Errorf("anchored cash %s wealth %s, want 2350.00", anchored.Cash, anchored.Wealth)
}
if len(anchored.Flows) == 0 || anchored.Flows[0].Event != "anchor" || anchored.Flows[0].Cash != "3300.00" {
t.Errorf("start balance line missing or wrong: %+v", anchored.Flows)
}
total := int64(0)
for _, flow := range anchored.Flows {
cash, err := flow.Cash.Minor()
if err != nil {
t.Fatal(err)
}
total += cash
}
if domain.FormatMoney(total) != anchored.Cash {
t.Errorf("flows sum to %s, balance is %s", domain.FormatMoney(total), anchored.Cash)
}
checks := map[string]WealthCheck{}
for _, check := range anchored.Checks {
checks[check.Name] = check
}
if _, ok := checks["Balance anchored"]; !ok {
t.Errorf("no anchor note: %+v", anchored.Checks)
}
if check := checks["Cash never negative"]; check.Failed {
t.Errorf("pre-anchor window judged as an overdraft: %s", check.Detail)
}
note := false
for _, check := range report.Accounts[1].Checks {
note = note || check.Name == "Balance not anchored"
}
if !note {
t.Errorf("connected account without an anchor carries no note: %+v", report.Accounts[1].Checks)
}
if report.Totals[0].Cash != "2360.00" {
t.Errorf("total cash %s, want 2360.00", report.Totals[0].Cash)
}
}
+14 -5
View File
@@ -12,6 +12,7 @@ import (
"strings"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
"finance-duck/internal/domain"
@@ -251,6 +252,13 @@ func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.D
return result, nil
}
// hasHiddenRunes reports control or format code points — bidi overrides,
// zero-width characters — that would let model-supplied text spoof or
// reorder review UI. Legitimate payee names never need them.
func hasHiddenRunes(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) })
}
// resolveAnswer maps one schema-valid provider answer onto enrichment,
// revalidating every id against the local registry. proposed collects newly
// minted merchants by normalized name so several rows resolved against the
@@ -280,11 +288,12 @@ func resolveAnswer(answer answer, facts domain.Facts, data domain.Dataset, candi
}
if answer.NewMerchant != nil {
name := strings.Join(strings.Fields(*answer.NewMerchant), " ")
// An identifier-shaped or oversized name is dropped, never stored, but
// the row keeps its independently enum-validated category and tags: a
// legitimate payee whose spelling trips the redactor (observed in the
// field) must not lose its whole classification.
if !utf8.ValidString(name) || utf8.RuneCountInString(name) > 100 || normalize(name) == "" || normalize(clean(name)) != normalize(name) {
// An identifier-shaped, oversized or hidden-rune name is dropped,
// never stored, but the row keeps its independently enum-validated
// category and tags: a legitimate payee whose spelling trips the
// redactor (observed in the field) must not lose its whole
// classification.
if !utf8.ValidString(name) || utf8.RuneCountInString(name) > 100 || normalize(name) == "" || normalize(clean(name)) != normalize(name) || hasHiddenRunes(name) {
// no merchant
} else if existing := duplicateMerchant(name, data.Merchants); existing != nil {
e.MerchantID = existing.ID
+1 -1
View File
@@ -343,7 +343,7 @@ func TestTransactionAmountAndCounterpartyAreSent(t *testing.T) {
}
func TestUnsafeMerchantProposalDroppedWithoutLosingClassification(t *testing.T) {
for _, name := range []string{"Alice Privateperson", "DE89370400440532013000", "Bank 123456789", "reference secretpayment", strings.Repeat("x", 101)} {
for _, name := range []string{"Alice Privateperson", "DE89370400440532013000", "Bank 123456789", "reference secretpayment", strings.Repeat("x", 101), "Rent \u202Edeifirev \u2713", "zero\u200Bwidth"} {
t.Run(name, func(t *testing.T) {
f, d := fixture()
f.Counterparty = "Alice Privateperson"
+1 -1
View File
@@ -83,7 +83,7 @@ func normalizedProposalName(value string, max int) (string, error) {
if !utf8.ValidString(value) || value == "" || utf8.RuneCountInString(value) > max {
return "", errors.New("proposal name is blank, invalid UTF-8 or too long")
}
if strings.ContainsAny(value, "{}[]()<>/\\") || strings.Contains(value, "___") {
if strings.ContainsAny(value, "{}[]()<>/\\") || strings.Contains(value, "___") || hasHiddenRunes(value) {
return "", errors.New("proposal name is identifier-shaped")
}
return value, nil
+24 -5
View File
@@ -250,6 +250,11 @@ func validHint(s string) bool {
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 200
}
// validName bounds registry display names at the 200 runes every UI form
// already enforces, so no client can persist an unbounded name that every
// later state response would carry.
func validName(s string) bool { return nonblank(s) && utf8.RuneCountInString(s) <= 200 }
// ValidISIN reports a syntactically valid ISIN: two country letters, nine
// alphanumerics and a check digit.
func ValidISIN(s string) bool { return isinPattern.MatchString(s) }
@@ -286,13 +291,27 @@ func Validate(d Dataset) error {
if a.Kind != "" && a.Kind != AccountCash && a.Kind != AccountInvestment {
return fmt.Errorf("account %q: kind must be %q or %q", a.ID, AccountCash, AccountInvestment)
}
// An anchor is one figure and the day it was true: neither half means
// anything alone, and anchoring an investment account would mask an
// incomplete broker history instead of exposing it.
if (a.AnchorBalance == "") != (a.AnchorDate == "") {
return fmt.Errorf("account %q: an anchor needs both a balance and its date", a.ID)
}
if a.AnchorDate != "" {
if a.Investing() {
return fmt.Errorf("account %q: a balance anchor belongs to a cash account; a broker export carries its complete history", a.ID)
}
if _, err := a.AnchorBalance.Minor(); err != nil || !validDate(a.AnchorDate) {
return fmt.Errorf("account %q: invalid anchor balance or date", a.ID)
}
}
accounts[a.ID] = a
}
for _, c := range d.Categories {
if err := register(c.ID, "category"); err != nil {
return err
}
if !nonblank(c.Name) || !validHint(c.Hint) || (c.Kind != "expense" && c.Kind != "income") {
if !validName(c.Name) || !validHint(c.Hint) || (c.Kind != "expense" && c.Kind != "income") {
return fmt.Errorf("category %q: invalid name, hint or kind", c.ID)
}
categories[c.ID] = c
@@ -330,7 +349,7 @@ func Validate(d Dataset) error {
if err := register(t.ID, "tag"); err != nil {
return err
}
if !nonblank(t.Name) || !validHint(t.Hint) {
if !validName(t.Name) || !validHint(t.Hint) {
return fmt.Errorf("tag %q: name or hint invalid", t.ID)
}
tags[t.ID] = true
@@ -339,8 +358,8 @@ func Validate(d Dataset) error {
if err := register(m.ID, "merchant"); err != nil {
return err
}
if !nonblank(m.Name) {
return fmt.Errorf("merchant %q: name required", m.ID)
if !validName(m.Name) {
return fmt.Errorf("merchant %q: valid name of at most 200 characters required", m.ID)
}
if m.DefaultCategoryID != "" {
if _, ok := categories[m.DefaultCategoryID]; !ok || children[m.DefaultCategoryID] {
@@ -375,7 +394,7 @@ func Validate(d Dataset) error {
if other, ok := isins[v.ISIN]; ok {
return fmt.Errorf("instrument %q: ISIN %s already held by %q", v.ID, v.ISIN, other)
}
if !nonblank(v.Name) || !currencyPattern.MatchString(v.Currency) || !validText(v.Symbol) {
if !validName(v.Name) || !currencyPattern.MatchString(v.Currency) || !validText(v.Symbol) {
return fmt.Errorf("instrument %q: valid UTF-8 name and symbol and three-letter uppercase currency required", v.ID)
}
// A quote without its day cannot be judged stale, and a day without a
+14
View File
@@ -75,6 +75,20 @@ func TestDomainRejectsBrokenReferencesAndTaxonomy(t *testing.T) {
{"duplicate identity", func(d *Dataset) { d.Tags[0].ID = "acc_main" }},
{"invalid provenance date", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Timestamp = "yesterday" }},
{"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }},
{"oversized tag name", func(d *Dataset) { d.Tags[0].Name = strings.Repeat("x", 201) }},
{"oversized category name", func(d *Dataset) { d.Categories[2].Name = strings.Repeat("x", 201) }},
{"anchor balance without its date", func(d *Dataset) { d.Accounts[1].AnchorBalance = "100.00" }},
{"anchor date without its balance", func(d *Dataset) { d.Accounts[1].AnchorDate = "2026-01-01" }},
{"anchored investment account", func(d *Dataset) {
d.Accounts[1].Kind = AccountInvestment
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "100.00", "2026-01-01"
}},
{"invalid anchor date", func(d *Dataset) {
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "100.00", "2026-02-30"
}},
{"invalid anchor balance", func(d *Dataset) {
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "1e2", "2026-01-01"
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+10
View File
@@ -31,6 +31,16 @@ type Account struct {
// broker exports no counterparty column, so deposits and withdrawals carry
// this IBAN instead and pair with the funding account like any transfer.
ReferenceIBAN string `json:"reference_iban,omitempty"`
// AnchorBalance is the bank's booked (CLBD) balance on AnchorDate, captured
// once from open banking after a sync. It fixes the start balance of a
// date-windowed history: the money that existed before the recorded rows is
// AnchorBalance less every movement booked through AnchorDate, so the
// account's real balance is computable without complete history. The bank's
// figure is stored verbatim — the start balance is derived, never stored —
// so importing older history later corrects the derivation by itself.
// Cash accounts only: a broker export carries its complete history.
AnchorBalance Money `json:"anchor_balance,omitempty"`
AnchorDate string `json:"anchor_date,omitempty"`
Active bool `json:"active"`
}
+89 -1
View File
@@ -11,6 +11,7 @@ import (
"net"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
@@ -47,6 +48,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
s.mux.HandleFunc("POST /api/instruments", s.instrument)
s.mux.HandleFunc("POST /api/assets", s.asset)
s.mux.HandleFunc("POST /api/transactions/{id}/transfer", s.transfer)
s.mux.HandleFunc("POST /api/transactions/bulk", s.bulkTransactions)
s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction)
s.mux.HandleFunc("POST /api/manage", s.manage)
s.mux.HandleFunc("POST /api/import/prepare", s.importPrepare)
@@ -237,7 +239,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
respond(w, nil, errors.New("from must not exceed to"))
return
}
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagID: q.Get("tag_id"), MerchantID: q.Get("merchant_id")})
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagIDs: q["tag_ids"], ExcludeTagIDs: q["exclude_tag_ids"], MerchantID: q.Get("merchant_id")})
respond(w, v, e)
}
func (s *Server) account(w http.ResponseWriter, r *http.Request) {
@@ -347,6 +349,92 @@ func (s *Server) transaction(w http.ResponseWriter, r *http.Request) {
})
respond(w, v, e)
}
func (s *Server) bulkTransactions(w http.ResponseWriter, r *http.Request) {
var b struct {
Revision string `json:"revision"`
TransactionIDs []string `json:"transaction_ids"`
CategoryID *string `json:"category_id"`
MerchantID *string `json:"merchant_id"`
AddTagIDs []string `json:"add_tag_ids"`
RemoveTagIDs []string `json:"remove_tag_ids"`
}
if !decode(w, r, &b) {
return
}
v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error {
if len(b.TransactionIDs) == 0 {
return errors.New("select at least one transaction")
}
if b.CategoryID == nil && b.MerchantID == nil && len(b.AddTagIDs) == 0 && len(b.RemoveTagIDs) == 0 {
return errors.New("choose at least one bulk edit")
}
selected := make(map[string]bool, len(b.TransactionIDs))
for _, id := range b.TransactionIDs {
if id == "" || selected[id] {
return errors.New("transaction IDs must be nonempty and unique")
}
selected[id] = true
}
var knownTags map[string]bool
if len(b.AddTagIDs) > 0 || len(b.RemoveTagIDs) > 0 {
knownTags = make(map[string]bool, len(d.Tags))
for _, tag := range d.Tags {
knownTags[tag.ID] = true
}
}
addTags := make(map[string]bool, len(b.AddTagIDs))
for _, id := range b.AddTagIDs {
if !knownTags[id] || addTags[id] {
return errors.New("added tag IDs must be known and unique")
}
addTags[id] = true
}
removeTags := make(map[string]bool, len(b.RemoveTagIDs))
for _, id := range b.RemoveTagIDs {
if !knownTags[id] || removeTags[id] || addTags[id] {
return errors.New("removed tag IDs must be known, unique and not also added")
}
removeTags[id] = true
}
matched := 0
provenance := domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)}
for i := range d.Transactions {
t := &d.Transactions[i]
if !selected[t.Facts.ID] {
continue
}
matched++
if (b.CategoryID != nil || b.MerchantID != nil) && (t.Enrichment.Kind == "transfer" || t.Enrichment.Kind == domain.KindInvestment) {
return errors.New("category and merchant cannot be edited on transfers or investments")
}
if b.CategoryID != nil {
t.Enrichment.CategoryID = *b.CategoryID
}
if b.MerchantID != nil {
t.Enrichment.MerchantID = *b.MerchantID
if *b.MerchantID != "" {
app.LearnAlias(d, t.Facts, *b.MerchantID)
}
}
if len(removeTags) > 0 {
t.Enrichment.TagIDs = slices.DeleteFunc(t.Enrichment.TagIDs, func(id string) bool { return removeTags[id] })
}
for _, id := range b.AddTagIDs {
if !slices.Contains(t.Enrichment.TagIDs, id) {
t.Enrichment.TagIDs = append(t.Enrichment.TagIDs, id)
}
}
t.Enrichment.Classification = provenance
}
if matched != len(selected) {
return errors.New("unknown transaction")
}
// Commit validates the complete dataset once, including category leaf/kind
// compatibility and merchant references, before writing any journal files.
return nil
})
respond(w, v, e)
}
func (s *Server) manage(w http.ResponseWriter, r *http.Request) {
var b struct {
Revision string `json:"revision"`
+339
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -11,12 +12,17 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"slices"
"strings"
"testing"
"testing/fstest"
"time"
"finance-duck/internal/analytics"
"finance-duck/internal/app"
"finance-duck/internal/banking"
"finance-duck/internal/domain"
)
func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
@@ -415,3 +421,336 @@ func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(t *testing.T) {
}
send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest)
}
func TestDashboardRepeatedTagFiltersOverHTTP(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
state, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
_, err = a.Mutate(context.Background(), state.Revision, func(data *domain.Dataset) error {
data.Accounts = append(data.Accounts, domain.Account{ID: "acc_eur", DisplayName: "Current", Currency: "EUR", Active: true})
data.Tags = append(data.Tags, domain.Tag{ID: "tag_shared", Name: "Shared"}, domain.Tag{ID: "tag_work", Name: "Work"})
for _, item := range []struct {
id string
amount domain.Money
kind string
category string
tags []string
}{
{"tx_both", "-10.0000", "expense", domain.ExpenseFallback, []string{"tag_shared", "tag_work"}},
{"tx_work", "-20.0000", "expense", domain.ExpenseFallback, []string{"tag_work"}},
{"tx_income", "100.0000", "income", domain.IncomeFallback, []string{}},
} {
data.Transactions = append(data.Transactions, domain.Transaction{
Facts: domain.Facts{ID: item.id, Source: "test", AccountID: "acc_eur", BookingDate: "2026-02-10",
Amount: item.amount, Currency: "EUR", RawDescription: item.id, Fingerprint: item.id},
Enrichment: domain.Enrichment{Kind: item.kind, CategoryID: item.category, TagIDs: item.tags},
})
}
return nil
})
if err != nil {
t.Fatal(err)
}
h, err := New(a, fstest.MapFS{}, "")
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
include []string
exclude []string
want []analytics.Total
}{
{
name: "repeated includes use union without duplication",
include: []string{"tag_shared", "tag_work"},
want: []analytics.Total{{Currency: "EUR", Expenses: "30.0000", Income: "0.0000", Net: "-30.0000"}},
},
{
name: "repeated exclusions preserve untagged income",
exclude: []string{"tag_shared", "tag_work"},
want: []analytics.Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.0000", Net: "100.0000"}},
},
{
name: "include and exclude compose with exclusion winning",
include: []string{"tag_shared", "tag_work"},
exclude: []string{"tag_missing", "tag_shared"},
want: []analytics.Total{{Currency: "EUR", Expenses: "20.0000", Income: "0.0000", Net: "-20.0000"}},
},
{
name: "comma separated values are not a list",
include: []string{"tag_shared,tag_work"},
want: []analytics.Total{},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
query := url.Values{"from": {"2026-02-01"}, "to": {"2026-02-28"}, "currency": {"EUR"}}
for _, id := range tt.include {
query.Add("tag_ids", id)
}
for _, id := range tt.exclude {
query.Add("exclude_tag_ids", id)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/dashboard?"+query.Encode(), nil))
if w.Code != http.StatusOK {
t.Fatalf("GET dashboard: %d: %s", w.Code, w.Body.String())
}
var got analytics.Dashboard
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got.Totals, tt.want) {
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.want)
}
})
}
}
func TestTransactionsBulkOverHTTP(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
t.Setenv("ENABLEBANKING_KEY_FILE", "")
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
a, err := app.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer a.Close()
state, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
_, err = a.Mutate(context.Background(), state.Revision, func(d *domain.Dataset) error {
d.Accounts = []domain.Account{
{ID: "acc_current", DisplayName: "Current", Currency: "EUR", Active: true},
{ID: "acc_savings", DisplayName: "Savings", Currency: "EUR", Active: true},
{ID: "acc_broker", DisplayName: "Broker", Currency: "EUR", Kind: domain.AccountInvestment, Active: true},
}
d.Categories = append(d.Categories, domain.Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"})
d.Tags = []domain.Tag{{ID: "tag_keep", Name: "Keep"}, {ID: "tag_remove", Name: "Remove"}, {ID: "tag_add", Name: "Add"}, {ID: "tag_absent", Name: "Absent"}}
d.Merchants = []domain.Merchant{{ID: "mer_old", Name: "Previous merchant"}, {ID: "mer_new", Name: "New merchant"}}
for _, item := range []struct {
id, account, kind, category, merchant, peer, counterparty string
amount domain.Money
tags []string
investment *domain.Investment
}{
{"tx_a", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Corner Bakery", "-10.0000", []string{"tag_keep", "tag_remove"}, nil},
{"tx_b", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Market Hall", "-20.0000", []string{"tag_add", "tag_keep"}, nil},
{"tx_untouched", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Station Kiosk", "-3.0000", []string{"tag_remove"}, nil},
{"tx_income", "acc_current", "income", domain.IncomeFallback, "", "", "Employer", "100.0000", []string{"tag_remove"}, nil},
{"tx_out", "acc_current", "transfer", "", "", "tx_in", "Savings", "-25.0000", []string{"tag_keep"}, nil},
{"tx_in", "acc_savings", "transfer", "", "", "tx_out", "Current", "25.0000", []string{}, nil},
{"tx_investment", "acc_broker", domain.KindInvestment, "", "", "", "Deposit", "30.0000", []string{"tag_keep"}, &domain.Investment{Event: domain.EventDeposit}},
} {
d.Transactions = append(d.Transactions, domain.Transaction{
Facts: domain.Facts{
ID: item.id, Source: "test", AccountID: item.account, BookingDate: "2026-02-10", ValueDate: "2026-02-11",
Amount: item.amount, Currency: "EUR", RawDescription: "Bank description " + item.id,
ExternalID: "external_" + item.id, Fingerprint: item.id, Counterparty: item.counterparty,
CounterpartyIBAN: "DE89370400440532013000", Investment: item.investment,
},
Enrichment: domain.Enrichment{
Kind: item.kind, CategoryID: item.category, MerchantID: item.merchant, TagIDs: item.tags,
TransferPeerID: item.peer, Classification: domain.Provenance{Source: "rules"},
},
})
}
return nil
})
if err != nil {
t.Fatal(err)
}
h, err := New(a, fstest.MapFS{}, "")
if err != nil {
t.Fatal(err)
}
snapshot := func() app.State {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/state", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET state: %d: %s", w.Code, w.Body.String())
}
var result app.State
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatal(err)
}
return result
}
post := func(body map[string]any, status int) app.State {
t.Helper()
if _, ok := body["revision"]; !ok {
body["revision"] = snapshot().Revision
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/transactions/bulk", strings.NewReader(string(raw)))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != status {
t.Fatalf("POST bulk: got %d, want %d: %s", w.Code, status, w.Body.String())
}
var result app.State
if status == http.StatusOK {
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatal(err)
}
persisted := snapshot()
if result.Revision != persisted.Revision || !reflect.DeepEqual(result.Data, persisted.Data) {
t.Fatal("bulk response differs from persisted state")
}
}
return result
}
transaction := func(s app.State, id string) domain.Transaction {
t.Helper()
for _, tx := range s.Data.Transactions {
if tx.Facts.ID == id {
return tx
}
}
t.Fatalf("missing transaction %s", id)
return domain.Transaction{}
}
for _, tt := range []struct {
name string
body map[string]any
}{
{"empty selection", map[string]any{"transaction_ids": []string{}, "add_tag_ids": []string{"tag_add"}}},
{"empty transaction ID", map[string]any{"transaction_ids": []string{"tx_a", ""}, "add_tag_ids": []string{"tag_add"}}},
{"duplicate transaction ID", map[string]any{"transaction_ids": []string{"tx_a", "tx_a"}, "add_tag_ids": []string{"tag_add"}}},
{"missing transaction rolls back category merchant tags and aliases", map[string]any{"transaction_ids": []string{"tx_a", "tx_missing"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}},
{"no operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{}, "remove_tag_ids": []string{}}},
{"unknown added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_missing"}}},
{"unknown removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_missing"}}},
{"duplicate added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add", "tag_add"}}},
{"duplicate removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_remove", "tag_remove"}}},
{"overlapping tag operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_add"}}},
{"nonleaf category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_expenses", "add_tag_ids": []string{"tag_add"}}},
{"unknown category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_missing", "merchant_id": "mer_new"}},
{"category cannot be cleared", map[string]any{"transaction_ids": []string{"tx_a"}, "category_id": ""}},
{"incompatible category rolls back entire batch", map[string]any{"transaction_ids": []string{"tx_a", "tx_income"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}},
{"unknown merchant", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": "mer_missing"}},
{"transfer category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "category_id": "cat_food"}},
{"transfer merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "merchant_id": ""}},
{"investment category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "category_id": "cat_food"}},
{"investment merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "merchant_id": ""}},
{"bank facts cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "amount": "1.0000", "add_tag_ids": []string{"tag_add"}}},
{"kind cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "kind": "income", "add_tag_ids": []string{"tag_add"}}},
{"transfer links cannot be edited", map[string]any{"transaction_ids": []string{"tx_out"}, "transfer_peer_id": "", "add_tag_ids": []string{"tag_add"}}},
} {
t.Run(tt.name, func(t *testing.T) {
before := snapshot()
post(tt.body, http.StatusBadRequest)
after := snapshot()
if before.Revision != after.Revision || !reflect.DeepEqual(before.Data, after.Data) {
t.Fatal("rejected batch changed persisted data or revision")
}
})
}
t.Run("multi row edit preserves facts unrelated tags and unselected rows", func(t *testing.T) {
before := snapshot()
after := post(map[string]any{
"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_food", "merchant_id": "mer_new",
"add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove", "tag_absent"},
}, http.StatusOK)
for _, old := range before.Data.Transactions {
got := transaction(after, old.Facts.ID)
if old.Facts.ID != "tx_a" && old.Facts.ID != "tx_b" {
if !reflect.DeepEqual(old, got) {
t.Fatalf("unselected transaction changed: %s", old.Facts.ID)
}
continue
}
tags := slices.Clone(got.Enrichment.TagIDs)
slices.Sort(tags)
if !reflect.DeepEqual(tags, []string{"tag_add", "tag_keep"}) || got.Enrichment.CategoryID != "cat_food" || got.Enrichment.MerchantID != "mer_new" {
t.Fatalf("bulk changes not applied: %+v", got.Enrichment)
}
if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind || got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID {
t.Fatalf("immutable transaction fields changed: %s", old.Facts.ID)
}
if got.Enrichment.Classification.Source != "manual" {
t.Fatalf("missing manual provenance: %+v", got.Enrichment.Classification)
}
if _, err := time.Parse(time.RFC3339, got.Enrichment.Classification.Timestamp); err != nil {
t.Fatalf("invalid manual timestamp: %v", err)
}
}
for _, merchant := range after.Data.Merchants {
if merchant.ID == "mer_new" && (!slices.Contains(merchant.Aliases, "Corner Bakery") || !slices.Contains(merchant.Aliases, "Market Hall")) {
t.Fatalf("explicit merchant assignment did not learn aliases: %+v", merchant)
}
}
post(map[string]any{"revision": before.Revision, "transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusConflict)
unchanged := snapshot()
if unchanged.Revision != after.Revision || !reflect.DeepEqual(unchanged.Data, after.Data) {
t.Fatal("stale batch overwrote the successful edit")
}
})
t.Run("tag-only edits preserve individual categories merchants and transfer links", func(t *testing.T) {
before := snapshot()
after := post(map[string]any{
"transaction_ids": []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"},
"add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove"},
}, http.StatusOK)
for _, id := range []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"} {
old, got := transaction(before, id), transaction(after, id)
if !slices.Contains(got.Enrichment.TagIDs, "tag_add") || slices.Contains(got.Enrichment.TagIDs, "tag_remove") {
t.Fatalf("tags not updated on %s: %+v", id, got.Enrichment)
}
if id == "tx_out" || id == "tx_investment" {
if !slices.Contains(got.Enrichment.TagIDs, "tag_keep") {
t.Fatalf("unrelated tag removed from %s", id)
}
}
if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind ||
got.Enrichment.CategoryID != old.Enrichment.CategoryID || got.Enrichment.MerchantID != old.Enrichment.MerchantID ||
got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID || got.Enrichment.Classification.Source != "manual" {
t.Fatalf("tag edit changed other fields or omitted manual provenance on %s: %+v", id, got)
}
}
if !reflect.DeepEqual(transaction(before, "tx_in"), transaction(after, "tx_in")) {
t.Fatal("tag edit changed unselected transfer counterpart")
}
if !reflect.DeepEqual(before.Data.Merchants, after.Data.Merchants) {
t.Fatal("tag-only edits learned merchant aliases")
}
})
t.Run("merchant clearing preserves category and tags and fallback remains selectable", func(t *testing.T) {
before := snapshot()
cleared := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusOK)
for _, id := range []string{"tx_a", "tx_b"} {
old, got := transaction(before, id), transaction(cleared, id)
if got.Enrichment.MerchantID != "" || old.Enrichment.CategoryID != got.Enrichment.CategoryID ||
!reflect.DeepEqual(old.Enrichment.TagIDs, got.Enrichment.TagIDs) || !reflect.DeepEqual(old.Facts, got.Facts) {
t.Fatalf("merchant clear changed unrelated fields: %+v", got)
}
}
if !reflect.DeepEqual(before.Data.Merchants, cleared.Data.Merchants) {
t.Fatal("merchant clearing changed aliases")
}
fallback := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": domain.ExpenseFallback}, http.StatusOK)
for _, id := range []string{"tx_a", "tx_b"} {
if transaction(fallback, id).Enrichment.CategoryID != domain.ExpenseFallback {
t.Fatalf("fallback category was not assigned to %s", id)
}
}
})
}
+42 -6
View File
@@ -1244,9 +1244,16 @@ function AccountEditor({
pattern="[A-Z]{3}"
maxLength={3}
value={value.currency}
onChange={(e) =>
setValue({ ...value, currency: e.target.value.toUpperCase() })
}
onChange={(e) => {
const currency = e.target.value.toUpperCase();
setValue((current) => ({
...current,
currency,
...(currency !== current.currency
? { anchor_balance: "", anchor_date: "" }
: {}),
}));
}}
/>
</Field>
</div>
@@ -1288,11 +1295,40 @@ function AccountEditor({
>
<input
value={value.external_account_id || ""}
onChange={(e) =>
setValue({ ...value, external_account_id: e.target.value })
}
onChange={(e) => {
const external = e.target.value;
setValue((current) => ({
...current,
external_account_id: external,
...(external !== (current.external_account_id || "")
? { anchor_balance: "", anchor_date: "" }
: {}),
}));
}}
/>
</Field>
{value.anchor_date && (
<Field
label="Balance anchor"
hint="The bank's booked balance, captured once after a sync. It fixes this account's start balance on Wealth. Clear it and the next synchronization captures a fresh one."
>
<div className="anchor-row">
<span>
{money(value.anchor_balance ?? "0", value.currency)} on{" "}
{value.anchor_date}
</span>
<button
type="button"
className="button subtle"
onClick={() =>
setValue({ ...value, anchor_balance: "", anchor_date: "" })
}
>
Clear anchor
</button>
</div>
</Field>
)}
<label className="checkbox">
<input
type="checkbox"
+50 -21
View File
@@ -16,7 +16,9 @@ import type {
} from "./api";
import { categoryPath, money, request } from "./api";
import {
CategoryCombobox,
Combobox,
createTag,
DateField,
Empty,
ErrorMessage,
@@ -24,12 +26,15 @@ import {
Modal,
ModelOptions,
} from "./ui";
import type { Mutate } from "./ui";
export function Classification({
state,
acceptState,
mutate,
}: {
state: State;
acceptState: (state: State, message?: string) => void;
mutate: Mutate;
}) {
const dates = state.data.transactions.map((t) => t.facts.booking_date).sort();
const [from, setFrom] = useState(dates[0] || "");
@@ -292,10 +297,12 @@ export function Classification({
setBusy(true);
setError("");
try {
// Refresh registry labels for the review, but let the server
// take its own snapshot so another write cannot race analysis.
acceptState(await request<State>("/api/state"));
const start = await request<PreviewProgress>(
"/api/reclassify/preview",
{
revision: state.revision,
from,
to,
model: model.trim(),
@@ -480,6 +487,7 @@ export function Classification({
value={effective(change)}
edited={change.id in edits}
disabled={busy}
mutate={mutate}
onChange={(value) => correct(change, value)}
/>
</div>
@@ -665,14 +673,18 @@ interface CorrectionValue {
}
// CorrectionEditor is the "Proposed" side of a review row, editable in place.
// Category and tags are free-text inputs that autocomplete against the
// existing taxonomy; the category list is limited to leaves of the change's
// kind because that is what validation will accept.
// existing taxonomy and can create a missing entry in place; the category
// list is limited to leaves of the change's kind because that is what
// validation will accept. Creating mid-review bumps the journal revision,
// which the apply path tolerates as long as the transactions themselves are
// untouched.
function CorrectionEditor({
data,
change,
value,
edited,
disabled,
mutate,
onChange,
}: {
data: Dataset;
@@ -680,15 +692,14 @@ function CorrectionEditor({
value: CorrectionValue;
edited: boolean;
disabled: boolean;
mutate: Mutate;
onChange: (value: CorrectionValue) => void;
}) {
const categories = data.categories
.filter(
(c) =>
c.kind === change.after.kind &&
!data.categories.some((child) => child.parent_id === c.id),
)
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
// Async creates resolve against the freshest correction, not the snapshot
// captured when the create row was clicked: a chip removed during the
// server round trip must survive the create landing.
const latest = useRef(value);
latest.current = value;
const addable = data.tags
.filter((t) => !value.tag_ids.includes(t.id))
.map((t) => ({ value: t.id, label: t.name }));
@@ -726,13 +737,16 @@ function CorrectionEditor({
<div>
<dt>Category</dt>
<dd>
<Combobox
options={categories}
<CategoryCombobox
data={data}
kind={change.after.kind}
leavesOnly
mutate={mutate}
value={value.category_id}
disabled={disabled}
onChange={(category_id) => onChange({ ...value, category_id })}
placeholder="Search categories"
emptyText="No matching category. Create it in Categories first."
onChange={(category_id) =>
onChange({ ...latest.current, category_id })
}
/>
</dd>
</div>
@@ -761,16 +775,31 @@ function CorrectionEditor({
<Combobox
options={addable}
value=""
disabled={disabled || !addable.length}
disabled={disabled}
onChange={(id) =>
onChange({ ...value, tag_ids: [...value.tag_ids, id] })
}
placeholder={
data.tags.length
? "Add tag"
: "No tags yet — create them in Tags"
placeholder={data.tags.length ? "Add tag" : "Add or create tag"}
emptyText="No matching tag. Type a name to create it."
create={(text) =>
data.tags.some(
(t) => t.name.toLowerCase() === text.toLowerCase(),
)
? []
: [
{
key: "tag",
label: `Create tag "${text}"`,
run: async () => {
const id = await createTag(mutate, data, text);
onChange({
...latest.current,
tag_ids: [...latest.current.tag_ids, id],
});
},
},
]
}
emptyText="No matching tag. Create it in Tags first."
/>
</div>
</dd>
+6 -3
View File
@@ -261,8 +261,11 @@ export function Overview({
setLoading(true);
setError("");
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter))
if (value) params.set(key, value);
for (const [key, value] of Object.entries(filter)) {
if (Array.isArray(value)) {
for (const id of value) params.append(key, id);
} else if (value) params.set(key, value);
}
request<Dashboard>(`/api/dashboard?${params}`, undefined, controller.signal)
.then((value) => {
for (const key of [
@@ -498,7 +501,7 @@ export function Overview({
groups={dashboard.tags.filter(
(g) => g.currency === currency,
)}
onSelect={(id) => drill({ tag_id: id })}
onSelect={(id) => drill({ tag_ids: [id] })}
/>
</div>
<Recurring
+125 -91
View File
@@ -5,6 +5,7 @@ import {
GitMerge,
Trash2,
FolderTree,
FolderPlus,
Tag as TagIcon,
Store,
CandlestickChart,
@@ -21,7 +22,7 @@ import type {
} from "./api";
import { categoryPath, request } from "./api";
import {
CategoryOptions,
CategoryCombobox,
Empty,
ErrorMessage,
Field,
@@ -32,6 +33,10 @@ import {
import type { Mutate } from "./ui";
type Entity = "category" | "tag" | "merchant" | "instrument";
type Item = Category | Tag | Merchant | Instrument;
const protectedCategoryIDs = new Set([
"cat_expenses_unclassified",
"cat_income_unclassified",
]);
const titles = {
category: "Categories",
tag: "Tags",
@@ -82,83 +87,105 @@ export function Registry({
? { id: "", isin: "", name: "", currency: "EUR", symbol: "" }
: { id: "", name: "" },
);
const row = (item: Item, depth = 0) => (
<div className="registry-row" key={item.id}>
<div
className="registry-label"
style={{ paddingLeft: `${depth * 23}px` }}
>
{entity === "category" ? (
<FolderTree size={18} />
) : entity === "tag" ? (
<TagIcon size={18} />
) : entity === "instrument" ? (
<CandlestickChart size={18} />
) : (
<Store size={18} />
)}
<div>
<strong>{item.name}</strong>
{"kind" in item && (
<small>
{item.kind}
{!item.parent_id ? " root" : ""}
</small>
)}
{"aliases" in item && (
<small>
{item.aliases.length ? item.aliases.join(" · ") : "No aliases"}
{item.use_defaults ? " · Defaults enabled" : ""}
</small>
)}
{"hint" in item && item.hint && <small>{item.hint}</small>}
{"isin" in item && (
<small>
{item.isin} · {item.currency} ·{" "}
{item.symbol
? item.quote
? `${item.symbol} at ${item.quote} on ${item.quoted_at}`
: `${item.symbol}, not yet quoted`
: "No market symbol, so unpriced"}
</small>
)}
</div>
</div>
{"default_category_id" in item && item.default_category_id && (
<span className="muted registry-detail">
{categoryPath(data, item.default_category_id)}
</span>
)}
<div className="row-actions">
<button
className="icon-button"
title={`Edit ${item.name}`}
aria-label={`Edit ${item.name}`}
onClick={() => setEditing(item)}
const createChild = (parent: Category) =>
setEditing({
id: "",
name: "",
parent_id: parent.id,
kind: parent.kind,
hint: "",
});
const row = (item: Item, depth = 0) => {
const category = entity === "category" && "kind" in item ? item : null;
return (
<div className="registry-row" key={item.id}>
<div
className="registry-label"
style={{ paddingLeft: `${depth * 23}px` }}
>
<Pencil size={16} />
</button>
{entity !== "instrument" && (
{entity === "category" ? (
<FolderTree size={18} />
) : entity === "tag" ? (
<TagIcon size={18} />
) : entity === "instrument" ? (
<CandlestickChart size={18} />
) : (
<Store size={18} />
)}
<div>
<strong>{item.name}</strong>
{"kind" in item && (
<small>
{item.kind}
{!item.parent_id ? " root" : ""}
</small>
)}
{"aliases" in item && (
<small>
{item.aliases.length ? item.aliases.join(" · ") : "No aliases"}
{item.use_defaults ? " · Defaults enabled" : ""}
</small>
)}
{"hint" in item && item.hint && <small>{item.hint}</small>}
{"isin" in item && (
<small>
{item.isin} · {item.currency} ·{" "}
{item.symbol
? item.quote
? `${item.symbol} at ${item.quote} on ${item.quoted_at}`
: `${item.symbol}, not yet quoted`
: "No market symbol, so unpriced"}
</small>
)}
</div>
</div>
{"default_category_id" in item && item.default_category_id && (
<span className="muted registry-detail">
{categoryPath(data, item.default_category_id)}
</span>
)}
<div className="row-actions">
{category && !protectedCategoryIDs.has(category.id) && (
<button
className="button subtle category-child-action"
title={`Add a child category under ${category.name}`}
aria-label={`Add a child category under ${category.name}`}
onClick={() => createChild(category)}
>
<FolderPlus size={15} />
Add child
</button>
)}
<button
className="icon-button"
title={`Merge ${item.name}`}
aria-label={`Merge ${item.name}`}
onClick={() => setAction({ item, action: "merge" })}
title={`Edit ${item.name}`}
aria-label={`Edit ${item.name}`}
onClick={() => setEditing(item)}
>
<GitMerge size={16} />
<Pencil size={16} />
</button>
)}
<button
className="icon-button danger"
title={`Delete ${item.name}`}
aria-label={`Delete ${item.name}`}
onClick={() => setAction({ item, action: "delete" })}
>
<Trash2 size={16} />
</button>
{entity !== "instrument" && (
<button
className="icon-button"
title={`Merge ${item.name}`}
aria-label={`Merge ${item.name}`}
onClick={() => setAction({ item, action: "merge" })}
>
<GitMerge size={16} />
</button>
)}
<button
className="icon-button danger"
title={`Delete ${item.name}`}
aria-label={`Delete ${item.name}`}
onClick={() => setAction({ item, action: "delete" })}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);
);
};
const tree = (
parent: string | undefined,
depth = 0,
@@ -181,7 +208,7 @@ export function Registry({
<h2>{titles[entity]}</h2>
<p>
{entity === "category"
? "A clear home for every transaction. Parent categories roll up their children."
? "Organize spending and income into a tree. Use Add child on any category to create a nested category."
: entity === "tag"
? "Flexible labels that work across your accounts and categories."
: entity === "instrument"
@@ -549,18 +576,19 @@ function RegistryEditor({
<option value="income">Income</option>
</select>
</Field>
<Field label="Parent category">
<select
<Field
label="Parent category"
hint="Choose an existing category to nest this one. Use Add child on the category list when you want to add a nested category."
>
<CategoryCombobox
data={data}
kind={kind}
exclude={[...descendants]}
emptyLabel="No parent (top level)"
value={parent}
onChange={(e) => setParent(e.target.value)}
>
<option value="">No parent (root)</option>
<CategoryOptions
data={data}
kind={kind}
exclude={[...descendants]}
/>
</select>
onChange={setParent}
placeholder="Choose a parent category"
/>
</Field>
<p className="muted">
Changing the parent moves this category and its entire subtree.
@@ -590,15 +618,21 @@ function RegistryEditor({
Use these defaults when this merchant is recognized
</label>
<Field label="Default category">
<select
<CategoryCombobox
data={data}
leavesOnly
emptyLabel="No default category"
mutate={mutate}
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">No default category</option>
<CategoryOptions data={data} />
</select>
onChange={setCategory}
/>
</Field>
<TagPicker data={data} value={tags} onChange={setTags} />
<TagPicker
data={data}
value={tags}
onChange={setTags}
mutate={mutate}
/>
<p className="muted">
Defaults are only used when explicitly enabled. Editing defaults
does not rewrite existing transactions.
+579 -114
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import {
Search,
ArrowUpRight,
@@ -18,7 +18,7 @@ import type {
} from "./api";
import { categoryPath, money } from "./api";
import {
CategoryOptions,
CategoryCombobox,
Empty,
ErrorMessage,
Field,
@@ -41,6 +41,26 @@ const EVENTS: Record<string, string> = {
corporate_action: "Corporate action",
position_transfer: "Position transfer",
};
// Human labels for classification provenance sources; the filter options and
// the Source column speak the same language. Both fallback shapes — the
// import-time "unclassified" error record and the plain sign-based
// "fallback" — read as Unclassified.
const CLASSIFICATIONS: Record<string, string> = {
manual: "Manual",
openrouter: "AI",
rule: "Merchant rule",
transfer_match: "Transfer match",
fallback: "Unclassified",
unclassified: "Unclassified",
};
const CLASSIFICATION_FILTERS: [string, string][] = [
["manual", "Manual"],
["openrouter", "AI"],
["rule", "Merchant rule"],
["transfer_match", "Transfer match"],
["unclassified", "Unclassified"],
];
// A corporate action or a position transfer moves shares between holdings and
// settles no money at all, so its zero amount is a fact and not a gap.
function positionOnly(investment?: Investment): boolean {
@@ -92,8 +112,16 @@ export function Transactions({
}) {
const [query, setQuery] = useState("");
const [needsReview, setNeedsReview] = useState(false);
const [status, setStatus] = useState("");
const [editing, setEditing] = useState<Transaction | null>(null);
const [page, setPage] = useState(0);
const [bulk, setBulk] = useState(false);
const [bulkEditing, setBulkEditing] = useState(false);
const selectionScope = JSON.stringify([filter, query, needsReview, status]);
const [selection, setSelection] = useState(() => ({
scope: selectionScope,
ids: new Set<string>(),
}));
const filtered = useMemo(() => {
const categories = new Set(filter.category_id ? [filter.category_id] : []);
let changed = true;
@@ -115,6 +143,7 @@ export function Transactions({
(!filter.from || f.booking_date >= filter.from) &&
(!filter.to || f.booking_date <= filter.to) &&
(!filter.currency || f.currency === filter.currency) &&
(!filter.account_id || f.account_id === filter.account_id) &&
(!filter.category_id || categories.has(e.category_id || "")) &&
(!needsReview ||
e.classification.confidence !== "high" ||
@@ -122,7 +151,15 @@ export function Transactions({
(e.kind === "income"
? "cat_income_unclassified"
: "cat_expenses_unclassified")) &&
(!filter.tag_id || e.tag_ids.includes(filter.tag_id)) &&
(!status ||
(status === "unclassified"
? ["fallback", "unclassified", ""].includes(
e.classification.source || "",
)
: e.classification.source === status)) &&
(!filter.tag_ids.length ||
filter.tag_ids.some((id) => e.tag_ids.includes(id))) &&
!filter.exclude_tag_ids.some((id) => e.tag_ids.includes(id)) &&
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
(!query ||
`${f.raw_description} ${f.counterparty || ""} ${data.merchants.find((m) => m.id === e.merchant_id)?.name || ""} ${f.amount}`
@@ -134,11 +171,44 @@ export function Transactions({
b.facts.booking_date.localeCompare(a.facts.booking_date) ||
a.facts.id.localeCompare(b.facts.id),
);
}, [data, filter, query, needsReview]);
}, [data, filter, query, needsReview, status]);
const currentPage = Math.min(
page,
Math.max(0, Math.ceil(filtered.length / 40) - 1),
);
const pageTransactions = filtered.slice(
currentPage * 40,
currentPage * 40 + 40,
);
const selectedTransactions = useMemo(
() => filtered.filter((tx) => selection.ids.has(tx.facts.id)),
[filtered, selection.ids],
);
// Reset before rendering children, including when shared filters change
// outside this view. A refresh may also remove rows from the matching set.
if (selection.scope !== selectionScope) {
setSelection({ scope: selectionScope, ids: new Set() });
setBulkEditing(false);
} else if (selectedTransactions.length !== selection.ids.size) {
setSelection({
scope: selectionScope,
ids: new Set(selectedTransactions.map((tx) => tx.facts.id)),
});
}
const selectedPageCount = pageTransactions.reduce(
(count, tx) => count + Number(selection.ids.has(tx.facts.id)),
0,
);
const clearSelection = () =>
setSelection({ scope: selectionScope, ids: new Set() });
const toggleSelected = (id: string) => {
setSelection((current) => {
const ids = new Set(current.ids);
if (ids.has(id)) ids.delete(id);
else ids.add(id);
return { scope: selectionScope, ids };
});
};
return (
<>
<div className="section-heading">
@@ -146,7 +216,21 @@ export function Transactions({
<h2>Transactions</h2>
<p>Your bank facts stay untouched. Make the meaning your own.</p>
</div>
<span className="badge neutral">{filtered.length} transactions</span>
<div className="bulk-heading-actions">
<span className="badge neutral">{filtered.length} transactions</span>
<button
type="button"
className="button secondary"
aria-pressed={bulk}
onClick={() => {
setBulk(!bulk);
setBulkEditing(false);
clearSelection();
}}
>
{bulk ? "Cancel bulk edit" : "Bulk edit"}
</button>
</div>
</div>
<Filters
data={data}
@@ -181,16 +265,108 @@ export function Transactions({
/>
Needs review
</label>
<select
className="toolbar-select"
aria-label="Classification status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
<option value="">All classifications</option>
{CLASSIFICATION_FILTERS.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<span className="muted small">
<SlidersHorizontal size={15} /> Click a transaction to edit
<SlidersHorizontal size={15} />{" "}
{bulk
? "Click a transaction to select"
: "Click a transaction to edit"}
</span>
</div>
{bulk && (
<div className="bulk-toolbar">
<div className="bulk-selection-summary">
<strong role="status" aria-live="polite">
{selectedTransactions.length} selected
</strong>
<span className="muted small">
Selection follows you across pages. Changing a filter clears it.
</span>
</div>
<div className="bulk-selection-actions">
<button
type="button"
className="button secondary"
disabled={
!filtered.length ||
selectedTransactions.length === filtered.length
}
onClick={() =>
setSelection({
scope: selectionScope,
ids: new Set(filtered.map((tx) => tx.facts.id)),
})
}
>
Select all {filtered.length} matching
</button>
<button
type="button"
className="button subtle"
disabled={!selectedTransactions.length}
onClick={clearSelection}
>
Clear selection
</button>
<button
type="button"
className="button primary"
disabled={!selectedTransactions.length}
onClick={() => setBulkEditing(true)}
>
Edit selected ({selectedTransactions.length})
</button>
</div>
</div>
)}
{filtered.length ? (
<>
<div className="table-scroll">
<table>
<thead>
<tr>
{bulk && (
<th className="transaction-selection">
<label className="transaction-select-control">
<input
type="checkbox"
aria-label={`Select all ${pageTransactions.length} transactions on this page`}
checked={
selectedPageCount === pageTransactions.length
}
ref={(input) => {
if (input)
input.indeterminate =
selectedPageCount > 0 &&
selectedPageCount < pageTransactions.length;
}}
onChange={(event) => {
const ids = new Set(selection.ids);
for (const tx of pageTransactions) {
if (event.target.checked) ids.add(tx.facts.id);
else ids.delete(tx.facts.id);
}
setSelection({ scope: selectionScope, ids });
}}
/>
</label>
</th>
)}
<th>Date / account</th>
<th>Transaction</th>
<th>Category / tags</th>
@@ -199,107 +375,128 @@ export function Transactions({
</tr>
</thead>
<tbody>
{filtered
.slice(currentPage * 40, currentPage * 40 + 40)
.map((tx) => {
const { facts: f, enrichment: e } = tx;
const investment = f.investment;
const moves = positionOnly(investment);
const security = data.instruments.find(
(i) => i.id === investment?.instrument_id,
);
return (
<tr key={f.id}>
<td>
<span className="nowrap">{f.booking_date}</span>
<small>
{data.accounts.find((a) => a.id === f.account_id)
?.display_name || f.account_id}
</small>
{pageTransactions.map((tx) => {
const { facts: f, enrichment: e } = tx;
const investment = f.investment;
const moves = positionOnly(investment);
const security = data.instruments.find(
(i) => i.id === investment?.instrument_id,
);
return (
<tr
key={f.id}
className={
bulk && selection.ids.has(f.id)
? "transaction-selected"
: undefined
}
>
{bulk && (
<td className="transaction-selection">
<label className="transaction-select-control">
<input
type="checkbox"
checked={selection.ids.has(f.id)}
aria-label={`Select ${f.booking_date}, ${f.raw_description}, ${money(f.amount, f.currency)}, ${data.accounts.find((a) => a.id === f.account_id)?.display_name || f.account_id}`}
onChange={() => toggleSelected(f.id)}
/>
</label>
</td>
<td>
<button
className="transaction-link"
onClick={() => setEditing(tx)}
>
<span className={`transaction-icon ${e.kind}`}>
{moves ? (
<Layers size={17} />
) : e.kind === "transfer" ? (
<ArrowLeftRight size={17} />
) : f.amount.startsWith("-") ? (
<ArrowUpRight size={17} />
) : (
<ArrowDownLeft size={17} />
)}
</span>
<span>
<strong>
{data.merchants.find(
(m) => m.id === e.merchant_id,
)?.name ||
f.counterparty ||
(investment
? security?.name || f.raw_description
: "Bank transaction")}
</strong>
{(!investment ||
f.raw_description !== security?.name) && (
<small className="description">
{f.raw_description}
</small>
)}
{investment && (
<small className="description">
{EVENTS[investment.event] ||
investment.event}
{investment.quantity
? ` · ${signedQuantity(investment.quantity)} shares`
: ""}
{investment.price
? ` @ ${investment.price} ${f.currency}`
: ""}
{moves ? " · position only, no cash" : ""}
</small>
)}
</span>
</button>
</td>
<td>
<span>
{e.kind === "transfer"
? "Own-account transfer"
: e.kind === "investment"
? "Investment ledger"
: categoryPath(data, e.category_id)}
</span>
<div className="chips">
{e.tag_ids.map((id) => (
<span className="badge" key={id}>
{data.tags.find((t) => t.id === id)?.name ||
id}
</span>
))}
</div>
</td>
<td>
<span className="badge neutral">
{e.classification.source}
</span>
{e.classification.error && (
<small className="text-danger">
Classification error
</small>
)}
</td>
<td
className={`numeric money ${f.amount.startsWith("-") ? "" : "positive"}`}
)}
<td>
<span className="nowrap">{f.booking_date}</span>
<small>
{data.accounts.find((a) => a.id === f.account_id)
?.display_name || f.account_id}
</small>
</td>
<td>
<button
className="transaction-link"
aria-pressed={
bulk ? selection.ids.has(f.id) : undefined
}
onClick={() =>
bulk ? toggleSelected(f.id) : setEditing(tx)
}
>
{money(f.amount, f.currency)}
</td>
</tr>
);
})}
<span className={`transaction-icon ${e.kind}`}>
{moves ? (
<Layers size={17} />
) : e.kind === "transfer" ? (
<ArrowLeftRight size={17} />
) : f.amount.startsWith("-") ? (
<ArrowUpRight size={17} />
) : (
<ArrowDownLeft size={17} />
)}
</span>
<span>
<strong>
{data.merchants.find(
(m) => m.id === e.merchant_id,
)?.name ||
f.counterparty ||
(investment
? security?.name || f.raw_description
: "Bank transaction")}
</strong>
{(!investment ||
f.raw_description !== security?.name) && (
<small className="description">
{f.raw_description}
</small>
)}
{investment && (
<small className="description">
{EVENTS[investment.event] || investment.event}
{investment.quantity
? ` · ${signedQuantity(investment.quantity)} shares`
: ""}
{investment.price
? ` @ ${investment.price} ${f.currency}`
: ""}
{moves ? " · position only, no cash" : ""}
</small>
)}
</span>
</button>
</td>
<td>
<span>
{e.kind === "transfer"
? "Own-account transfer"
: e.kind === "investment"
? "Investment ledger"
: categoryPath(data, e.category_id)}
</span>
<div className="chips">
{e.tag_ids.map((id) => (
<span className="badge" key={id}>
{data.tags.find((t) => t.id === id)?.name || id}
</span>
))}
</div>
</td>
<td>
<span className="badge neutral">
{CLASSIFICATIONS[e.classification.source] ??
e.classification.source}
</span>
{e.classification.error && (
<small className="text-danger">
Classification error
</small>
)}
</td>
<td
className={`numeric money ${f.amount.startsWith("-") ? "" : "positive"}`}
>
{money(f.amount, f.currency)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -352,9 +549,271 @@ export function Transactions({
close={() => setEditing(null)}
/>
)}
{bulkEditing && selectedTransactions.length > 0 && (
<BulkTransactionEditor
data={data}
transactions={selectedTransactions}
mutate={mutate}
close={() => setBulkEditing(false)}
saved={() => {
setBulkEditing(false);
clearSelection();
}}
/>
)}
</>
);
}
function BulkTransactionEditor({
data,
transactions,
mutate,
close,
saved,
}: {
data: Dataset;
transactions: Transaction[];
mutate: Mutate;
close: () => void;
saved: () => void;
}) {
const [categoryMode, setCategoryMode] = useState("keep");
const [categoryId, setCategoryId] = useState("");
const [merchantMode, setMerchantMode] = useState("keep");
const [merchantId, setMerchantId] = useState("");
const [addTagIds, setAddTagIds] = useState<string[]>([]);
const [removeTagIds, setRemoveTagIds] = useState<string[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submitting = useRef(false);
const canEditMerchant = transactions.every(
(tx) => tx.enrichment.kind === "expense" || tx.enrichment.kind === "income",
);
const categoryKind = transactions[0].enrichment.kind;
const canEditCategory =
canEditMerchant &&
transactions.every((tx) => tx.enrichment.kind === categoryKind);
const operations: string[] = [];
if (categoryMode === "set" && categoryId)
operations.push(`Set category to ${categoryPath(data, categoryId)}`);
if (merchantMode === "assign" && merchantId)
operations.push(
`Set merchant to ${data.merchants.find((m) => m.id === merchantId)?.name || merchantId}`,
);
if (merchantMode === "clear") operations.push("Clear merchant");
if (addTagIds.length)
operations.push(
`Add tags: ${addTagIds.map((id) => data.tags.find((tag) => tag.id === id)?.name || id).join(", ")}`,
);
if (removeTagIds.length)
operations.push(
`Remove tags: ${removeTagIds.map((id) => data.tags.find((tag) => tag.id === id)?.name || id).join(", ")}`,
);
const valid =
operations.length > 0 &&
(categoryMode === "keep" || (canEditCategory && !!categoryId)) &&
(merchantMode === "keep" ||
(canEditMerchant && (merchantMode === "clear" || !!merchantId)));
const closeWhenIdle = () => {
if (!submitting.current) close();
};
return (
<Modal
title={`Edit ${transactions.length} selected transactions`}
close={closeWhenIdle}
dismissible={!busy}
wide
>
<form
aria-busy={busy}
onSubmit={async (event) => {
event.preventDefault();
if (submitting.current || !valid) return;
submitting.current = true;
setBusy(true);
setError("");
const body: Record<string, unknown> = {
transaction_ids: transactions.map((tx) => tx.facts.id),
};
if (categoryMode === "set") body.category_id = categoryId;
if (merchantMode !== "keep")
body.merchant_id = merchantMode === "clear" ? "" : merchantId;
if (addTagIds.length) body.add_tag_ids = addTagIds;
if (removeTagIds.length) body.remove_tag_ids = removeTagIds;
try {
await mutate(
"/api/transactions/bulk",
body,
`${transactions.length} transactions updated`,
);
saved();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
submitting.current = false;
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<p className="muted">
Choose only the fields to change. Every chosen operation applies to
all {transactions.length} selected transactions, or none are saved.
</p>
<fieldset
className="bulk-edit-fields"
disabled={busy}
aria-label="Bulk changes"
>
<div className="two-columns">
<div className="bulk-field-group">
<Field
label="Category change"
hint={
!canEditMerchant
? "Category changes are unavailable because the selection includes a transfer or investment. Tags can still be edited for every selected row."
: !canEditCategory
? "Category changes require only expenses or only income. This selection contains both; no rows will be skipped."
: "Choose a compatible leaf category, including Unclassified. Categories cannot be cleared."
}
>
<select
value={categoryMode}
disabled={!canEditCategory}
onChange={(event) => setCategoryMode(event.target.value)}
>
<option value="keep">Leave category unchanged</option>
<option value="set">Set category</option>
</select>
</Field>
{categoryMode === "set" && canEditCategory && (
<Field label="New category">
<CategoryCombobox
data={data}
kind={categoryKind}
leavesOnly
required
disabled={busy}
placeholder="Choose a category"
value={categoryId}
onChange={setCategoryId}
/>
</Field>
)}
</div>
<div className="bulk-field-group">
<Field
label="Merchant change"
hint={
canEditMerchant
? "Assign a merchant or explicitly clear it for every selected transaction."
: "Merchant changes are unavailable because the selection includes a transfer or investment. Tags can still be edited for every selected row."
}
>
<select
value={merchantMode}
disabled={!canEditMerchant}
onChange={(event) => setMerchantMode(event.target.value)}
>
<option value="keep">Leave merchant unchanged</option>
<option value="assign">Assign merchant</option>
<option value="clear">Clear merchant</option>
</select>
</Field>
{merchantMode === "assign" && canEditMerchant && (
<Field label="New merchant">
<select
value={merchantId}
required
onChange={(event) => setMerchantId(event.target.value)}
>
<option value="">Choose a merchant</option>
{data.merchants.map((merchant) => (
<option key={merchant.id} value={merchant.id}>
{merchant.name}
</option>
))}
</select>
</Field>
)}
</div>
</div>
<div className="bulk-field-group">
<p className="muted small">
Other tags stay unchanged. Choosing a tag in one group removes
it from the other group.
</p>
<TagPicker
label="Add tags to every selected transaction"
data={data}
value={addTagIds}
onChange={(ids) => {
setAddTagIds(ids);
setRemoveTagIds((current) =>
current.filter((id) => !ids.includes(id)),
);
}}
/>
<TagPicker
label="Remove tags from every selected transaction"
data={data}
value={removeTagIds}
onChange={(ids) => {
setRemoveTagIds(ids);
setAddTagIds((current) =>
current.filter((id) => !ids.includes(id)),
);
}}
/>
</div>
</fieldset>
<section
className="bulk-operation-summary"
aria-label="Changes to apply"
aria-live="polite"
>
<h3>Apply to {transactions.length} transactions</h3>
{operations.length ? (
<ul>
{operations.map((operation, index) => (
<li key={index}>{operation}</li>
))}
</ul>
) : (
<p className="muted">No changes chosen yet.</p>
)}
<p className="muted small">
Unselected fields stay unchanged. Saving marks each selected row
as manually classified. Bank facts, transaction kinds and transfer
links never change.
</p>
</section>
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={closeWhenIdle}
disabled={busy}
>
Cancel
</button>
<button
type="submit"
className="button primary"
disabled={busy || !valid}
>
{busy
? "Applying…"
: `Apply to ${transactions.length} transactions`}
</button>
</div>
</form>
</Modal>
);
}
function TransactionEditor({
data,
transaction,
@@ -452,17 +911,22 @@ function TransactionEditor({
)}
</div>
{value.kind !== "transfer" && value.kind !== "investment" && (
<Field label="Category">
<select
<Field
label="Category"
hint="Choose a leaf category. To add one now, type a new name and choose “Create category”. Use “Parent / Child” to nest it under an existing category."
>
<CategoryCombobox
data={data}
kind={value.kind}
leavesOnly
required
mutate={mutate}
placeholder="Select or type to add a category"
value={value.category_id || ""}
onChange={(e) =>
setValue({ ...value, category_id: e.target.value })
onChange={(category_id) =>
setValue((v) => ({ ...v, category_id }))
}
>
<option value="">Choose category</option>
<CategoryOptions data={data} kind={value.kind} />
</select>
/>
</Field>
)}
<TransferLink
@@ -474,7 +938,8 @@ function TransactionEditor({
<TagPicker
data={data}
value={value.tag_ids}
onChange={(tag_ids) => setValue({ ...value, tag_ids })}
onChange={(tag_ids) => setValue((v) => ({ ...v, tag_ids }))}
mutate={mutate}
/>
<details open>
<summary>
+9 -3
View File
@@ -309,8 +309,11 @@ export default function WealthPage({
<dt>Completeness</dt>
<dd>
Cash equals the real balance only when the journal holds
that account's full history: a broker export does, a
date-windowed bank statement does not.
that account&rsquo;s full history: a broker export does, a
date-windowed bank statement does not. A connected bank
account closes that gap with an anchor the bank&rsquo;s
own booked balance, captured once from which the start
balance before the recorded rows is derived.
</dd>
</div>
</dl>
@@ -547,7 +550,10 @@ function AssetsPanel({
</p>
</div>
<div className="row-actions">
<button className="button secondary" onClick={() => setEditing(blank)}>
<button
className="button secondary"
onClick={() => setEditing(blank)}
>
<Plus size={16} />
Add asset
</button>
+9 -2
View File
@@ -11,6 +11,11 @@ export interface Account {
// against: a broker export carries no counterparty, so its deposits and
// withdrawals pair with the funding account through this IBAN.
reference_iban?: string;
// anchor_balance is the bank's booked balance on anchor_date, captured once
// from open banking after a sync. It fixes the start balance of a
// date-windowed history; clearing both lets the next sync re-anchor.
anchor_balance?: string;
anchor_date?: string;
active: boolean;
}
// Instrument is a security held in an investment account. The ISIN is the
@@ -209,7 +214,8 @@ export interface Filter {
currency: string;
account_id: string;
category_id: string;
tag_id: string;
tag_ids: string[];
exclude_tag_ids: string[];
merchant_id: string;
}
export interface Preview {
@@ -573,7 +579,8 @@ export const emptyFilter: Filter = {
currency: "",
account_id: "",
category_id: "",
tag_id: "",
tag_ids: [],
exclude_tag_ids: [],
merchant_id: "",
};
// A six-month window is the default view: long enough to show a trend and a
+45 -23
View File
@@ -64,7 +64,39 @@ function App() {
const [refreshing, setRefreshing] = useState(false);
const [notice, setNotice] = useState("");
const [mobileNav, setMobileNav] = useState(false);
const [filter, setFilter] = useState(defaultFilter);
const [filter, setFilter] = useState(() => {
const initial = defaultFilter();
try {
const saved = JSON.parse(
localStorage.getItem("finance-duck.tag-filters") || "null",
);
for (const key of ["tag_ids", "exclude_tag_ids"] as const) {
if (Array.isArray(saved?.[key])) {
initial[key] = [
...new Set<string>(
saved[key].filter((id: unknown) => typeof id === "string" && id),
),
];
}
}
} catch {
// Unavailable storage or an invalid saved value must not block the journal.
}
return initial;
});
useEffect(() => {
try {
localStorage.setItem(
"finance-duck.tag-filters",
JSON.stringify({
tag_ids: filter.tag_ids,
exclude_tag_ids: filter.exclude_tag_ids,
}),
);
} catch {
// Filters still work for this visit when browser storage is unavailable.
}
}, [filter.tag_ids, filter.exclude_tag_ids]);
const acceptState = useCallback((value: State, message?: string) => {
setState(normalizeState(value));
setConflict(false);
@@ -136,13 +168,12 @@ function App() {
"/api/rebuild",
].includes(path);
try {
acceptState(
await request<State>(
path,
revisionless ? body : { revision: state.revision, ...body },
),
message,
const next = await request<State>(
path,
revisionless ? body : { revision: state.revision, ...body },
);
acceptState(next, message);
return next;
} catch (err) {
if (err instanceof APIError && err.status === 409) setConflict(true);
throw err;
@@ -352,7 +383,6 @@ function App() {
)}
{page === "transactions" && (
<Transactions
key={state.revision}
data={state.data}
filter={filter}
setFilter={setFilter}
@@ -361,7 +391,6 @@ function App() {
)}
{page === "categories" && (
<Registry
key={`categories-${state.revision}`}
entity="category"
data={state.data}
mutate={mutate}
@@ -371,24 +400,13 @@ function App() {
/>
)}
{page === "tags" && (
<Registry
key={`tags-${state.revision}`}
entity="tag"
data={state.data}
mutate={mutate}
/>
<Registry entity="tag" data={state.data} mutate={mutate} />
)}
{page === "merchants" && (
<Registry
key={`merchants-${state.revision}`}
entity="merchant"
data={state.data}
mutate={mutate}
/>
<Registry entity="merchant" data={state.data} mutate={mutate} />
)}
{page === "instruments" && (
<Registry
key={`instruments-${state.revision}`}
entity="instrument"
data={state.data}
mutate={mutate}
@@ -409,7 +427,11 @@ function App() {
/>
)}
{page === "classification" && (
<Classification state={state} acceptState={acceptState} />
<Classification
state={state}
acceptState={acceptState}
mutate={mutate}
/>
)}
{page === "settings" && (
<Settings
+201
View File
@@ -718,6 +718,33 @@ main {
padding: 17px 23px;
border-bottom: 1px solid var(--line);
}
.bulk-heading-actions,
.bulk-selection-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.bulk-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 17px 23px;
border-bottom: 1px solid var(--line);
background: #f5faf7;
}
.bulk-selection-summary {
display: flex;
flex-direction: column;
gap: 5px;
}
.bulk-selection-summary strong {
color: var(--emerald-dark);
font-size: 13px;
}
.search {
display: flex;
align-items: center;
@@ -744,6 +771,16 @@ main {
.search input::placeholder {
color: #9aa6b3;
}
.toolbar-select {
height: 35px;
font-size: 11px;
padding: 0 9px;
border: 1px solid #dbe2ea;
border-radius: 5px;
background: #fff;
color: #46596a;
flex-shrink: 0;
}
.table-scroll {
overflow-x: auto;
}
@@ -782,6 +819,29 @@ td small {
tbody tr:hover {
background: #fcfefd;
}
.transaction-selection {
width: 54px;
padding: 8px 10px 8px 14px;
}
.transaction-select-control {
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
min-height: 36px;
cursor: pointer;
}
.transaction-select-control input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--emerald);
cursor: pointer;
}
.transaction-selected,
.transaction-selected:hover {
background: #eef8f3;
}
.numeric {
text-align: right;
}
@@ -893,6 +953,11 @@ tbody tr:hover {
gap: 5px;
align-items: center;
}
.category-child-action {
min-height: 32px;
padding: 6px 9px;
font-size: 11px;
}
.modal {
border: 1px solid #dce5eb;
border-radius: 12px;
@@ -952,6 +1017,38 @@ tbody tr:hover {
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.bulk-edit-fields,
.bulk-field-group {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.bulk-edit-fields {
border: 0;
padding: 0;
margin: 0;
gap: 20px;
}
.bulk-operation-summary {
border: 1px solid var(--line);
border-radius: 6px;
padding: 16px;
background: #f5faf7;
overflow-wrap: anywhere;
}
.bulk-operation-summary h3 {
font-size: 14px;
}
.bulk-operation-summary ul {
padding-left: 20px;
margin: 12px 0;
line-height: 1.8;
font-size: 12px;
}
.bulk-operation-summary > p {
margin-top: 10px;
}
.tag-picker {
border: 1px solid var(--line);
border-radius: 6px;
@@ -966,6 +1063,23 @@ tbody tr:hover {
color: #546779;
padding: 0 5px;
}
/* Inline tag creation inside the picker: a small input plus one button, so a
missing tag never forces a detour through the Tags page. */
.tag-add {
display: inline-flex;
align-items: center;
gap: 5px;
}
.tag-add input {
width: 140px;
padding: 6px 9px;
font-size: 12px;
}
.tag-add-error {
flex-basis: 100%;
color: var(--danger);
font-size: 12px;
}
.check-chip {
display: inline-flex;
align-items: center;
@@ -1640,6 +1754,15 @@ footer span:first-child {
width: 238px;
transition: transform 0.2s;
}
/* With the classification select beside the review toggle, the search
would shrink to a sliver on phones; give it its own full-width row. */
.panel-toolbar {
flex-wrap: wrap;
}
.search {
flex-basis: 100%;
max-width: none;
}
.sidebar.open {
transform: translateX(0);
}
@@ -1702,6 +1825,26 @@ footer span:first-child {
font-size: 11px;
line-height: 1.6;
}
.bulk-heading-actions {
flex-shrink: 0;
flex-direction: column;
align-items: flex-end;
}
.bulk-heading-actions .button {
font-size: 11px;
white-space: nowrap;
}
.bulk-toolbar {
padding: 15px;
}
.bulk-selection-actions {
justify-content: flex-start;
width: 100%;
}
.bulk-selection-actions .button {
flex: 1 1 auto;
font-size: 11px;
}
.filters {
padding: 13px;
gap: 11px;
@@ -2059,6 +2202,7 @@ footer span:first-child {
color: inherit;
}
.combo-option:hover,
.combo-option.active,
.combo-option[aria-selected="true"] {
background: #f0f7f4;
}
@@ -2075,6 +2219,17 @@ footer span:first-child {
color: var(--muted);
font-size: 12px;
}
.combo-option.create {
color: var(--emerald);
font-weight: 600;
}
.combo-option.create svg {
width: 14px;
height: 14px;
}
.combo-empty.error {
color: var(--danger);
}
/* The proposed side of a review row is editable in place: compact combobox
inputs so a correction fits the diff card, removable chips for tags. */
.diff-value .combo > input {
@@ -2332,6 +2487,43 @@ footer span:first-child {
padding-top: 13px;
background: transparent;
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: 14px 20px;
padding: 0 18px 17px;
}
.tag-filter {
flex: 1 1 250px;
min-width: 0;
}
.tag-filter .field {
gap: 6px;
}
.tag-filter .combo > input {
min-height: 35px;
padding: 7px 9px;
font-size: 12px;
background: #fcfdfe;
}
.tag-filter .tag-edit {
margin-top: 8px;
}
.tag-filter .tag-chip {
min-height: 32px;
max-width: 100%;
text-align: left;
}
.tag-filter .tag-chip span {
overflow-wrap: anywhere;
}
.tag-filter .tag-chip svg {
flex-shrink: 0;
}
.tag-filter .tag-chip.excluded {
border-color: #e8cece;
color: var(--danger);
}
.chip {
border: 1px solid #dde4ea;
background: #fcfdfe;
@@ -2611,6 +2803,9 @@ footer span:first-child {
.range-row .filter-reset {
margin-left: 0;
}
.tag-filters {
padding: 0 13px 13px;
}
.chart-body {
padding: 4px 12px 18px;
}
@@ -2627,3 +2822,9 @@ footer span:first-child {
font-size: 10px;
}
}
.anchor-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
+411 -44
View File
@@ -7,8 +7,9 @@ import {
CalendarDays,
ChevronLeft,
ChevronRight,
Plus,
} from "lucide-react";
import type { Dataset, Filter, VerifiedModel } from "./api";
import type { Category, Dataset, Filter, State, VerifiedModel } from "./api";
import {
categoryPath,
DEFAULT_MONTHS,
@@ -22,11 +23,13 @@ export function Modal({
children,
close,
wide = false,
dismissible = true,
}: {
title: string;
children: ReactNode;
close: () => void;
wide?: boolean;
dismissible?: boolean;
}) {
const ref = useRef<HTMLDialogElement>(null);
const titleID = useId();
@@ -42,7 +45,7 @@ export function Modal({
className={wide ? "modal wide" : "modal"}
onCancel={(e) => {
e.preventDefault();
close();
if (dismissible) close();
}}
>
<div className="modal-header">
@@ -51,6 +54,7 @@ export function Modal({
className="icon-button"
aria-label="Close dialog"
onClick={close}
disabled={!dismissible}
>
<X size={20} />
</button>
@@ -103,6 +107,14 @@ export interface ComboOption {
label: string;
icon?: ReactNode;
}
// ComboCreate is one "create it now" row a Combobox offers when the typed
// text matches nothing: running it is expected to persist the new entity and
// select it through the caller's own onChange.
export interface ComboCreate {
key: string;
label: string;
run: () => Promise<void> | void;
}
// Combobox is a free-text input that autocompletes against a fixed option
// list: typing filters by label, Enter takes the exact or only match, and
// picking an option reports its value. The caller keeps working with stable
@@ -116,6 +128,7 @@ export function Combobox({
required = false,
adornment,
emptyText = "No matches.",
create,
}: {
options: ComboOption[];
value: string;
@@ -125,9 +138,16 @@ export function Combobox({
required?: boolean;
adornment?: ReactNode;
emptyText?: string;
create?: (text: string) => ComboCreate[];
}) {
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
// Index into the interactive rows (matches first, then create rows); -1
// means no row is armed and Enter falls back to exact/single-match logic.
const [active, setActive] = useState(-1);
const listID = useId();
const filter = query.trim().toLowerCase();
const matches = options.filter((o) => o.label.toLowerCase().includes(filter));
const exact = filter
@@ -137,10 +157,38 @@ export function Combobox({
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
: matches.slice(0, 60);
const selected = options.find((o) => o.value === value);
const creations =
create && filter && !exact && !disabled ? create(query.trim()) : [];
const total = shown.length + creations.length;
const cursor = active < total ? active : -1;
// The dropdown scrolls at 264px; keep the armed row visible while
// arrowing through a long category list.
useEffect(() => {
if (cursor < 0) return;
document
.getElementById(`${listID}-${cursor}`)
?.scrollIntoView({ block: "nearest" });
}, [cursor, listID]);
const pick = (v: string) => {
onChange(v);
setOpen(false);
};
const runCreate = async (c: ComboCreate) => {
if (creating) return;
setCreating(true);
setCreateError("");
try {
await c.run();
setOpen(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : String(err));
// A blur may have closed the list mid-flight; a failure must never
// land invisibly.
setOpen(true);
} finally {
setCreating(false);
}
};
return (
<div className="combo">
<input
@@ -148,24 +196,52 @@ export function Combobox({
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
aria-controls={open ? listID : undefined}
aria-activedescendant={
open && cursor >= 0 ? `${listID}-${cursor}` : undefined
}
disabled={disabled}
value={open ? query : (selected?.label ?? value)}
placeholder={placeholder}
onFocus={() => {
setQuery("");
setActive(-1);
setOpen(true);
}}
onChange={(e) => {
setQuery(e.target.value);
setCreateError("");
setActive(-1);
setOpen(true);
}}
onBlur={() => setOpen(false)}
onBlur={() => {
// A blur during an in-flight create keeps the list mounted so the
// outcome (or the error row) stays visible.
if (!creating) setOpen(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && open && total) {
e.preventDefault();
setActive(
e.key === "ArrowDown"
? (cursor + 1) % total
: (cursor <= 0 ? total : cursor) - 1,
);
}
if (e.key === "Enter" && open) {
e.preventDefault();
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
if (hit) pick(hit.value);
if (cursor >= 0 && cursor < shown.length) pick(shown[cursor].value);
else if (cursor >= shown.length)
void runCreate(creations[cursor - shown.length]);
else {
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
if (hit) pick(hit.value);
// Without an armed row, Enter creates only when nothing
// matches at all: minting from a half-typed name is too easy.
else if (!shown.length && creations.length === 1)
void runCreate(creations[0]);
}
}
}}
/>
@@ -173,14 +249,18 @@ export function Combobox({
<span className="combo-adornment">{adornment}</span>
)}
{open && (
<ul className="combo-options" role="listbox">
{shown.map((o) => (
<ul className="combo-options" role="listbox" id={listID}>
{shown.map((o, i) => (
<li key={o.value}>
<button
type="button"
className="combo-option"
id={`${listID}-${i}`}
className={
i === cursor ? "combo-option active" : "combo-option"
}
role="option"
aria-selected={o.value === value}
disabled={creating}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(o.value)}
>
@@ -189,7 +269,35 @@ export function Combobox({
</button>
</li>
))}
{shown.length === 0 && <li className="combo-empty">{emptyText}</li>}
{creations.map((c, i) => (
<li key={c.key}>
<button
type="button"
id={`${listID}-${shown.length + i}`}
className={
shown.length + i === cursor
? "combo-option create active"
: "combo-option create"
}
role="option"
aria-selected={false}
disabled={creating}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void runCreate(c)}
>
<Plus size={14} />
<span>{creating ? "Creating…" : c.label}</span>
</button>
</li>
))}
{createError && (
<li className="combo-empty error" role="alert">
{createError}
</li>
)}
{shown.length === 0 && creations.length === 0 && !createError && (
<li className="combo-empty">{emptyText}</li>
)}
{matches.length > shown.length && (
<li className="combo-empty">
{matches.length - shown.length} more keep typing to narrow down.
@@ -461,38 +569,146 @@ export function Empty({
</div>
);
}
// createTag persists a new tag and returns its server-minted id, found by
// diffing the returned state against the dataset the caller rendered with.
export async function createTag(
mutate: Mutate,
data: Dataset,
name: string,
): Promise<string> {
if (name.length > 200)
throw new Error("Tag names are limited to 200 characters.");
const next = await mutate(
"/api/tags",
{ tag: { id: "", name, hint: "" } },
`Tag "${name}" created`,
);
const created = next.data.tags.find(
(t) => !data.tags.some((o) => o.id === t.id),
);
if (!created)
throw new Error(`The server did not return the new tag "${name}".`);
return created.id;
}
export async function createCategory(
mutate: Mutate,
data: Dataset,
category: { name: string; parent_id: string; kind: string },
): Promise<string> {
if (category.name.length > 200)
throw new Error("Category names are limited to 200 characters.");
const next = await mutate(
"/api/categories",
{ category: { id: "", hint: "", ...category } },
`Category "${category.name}" created`,
);
const created = next.data.categories.find(
(c) => !data.categories.some((o) => o.id === c.id),
);
if (!created)
throw new Error(
`The server did not return the new category "${category.name}".`,
);
return created.id;
}
export function TagPicker({
data,
value,
onChange,
mutate,
label = "Tags",
}: {
data: Dataset;
value: string[];
onChange: (ids: string[]) => void;
mutate?: Mutate;
label?: string;
}) {
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// The async add resolves against the freshest selection, not the one
// captured at click time: a checkbox toggled during the server round trip
// must survive the create landing.
const latest = useRef(value);
latest.current = value;
const add = async () => {
const name = draft.trim();
if (!name || busy || !mutate) return;
// An existing tag of the same name is checked instead of duplicated.
const existing = data.tags.find(
(t) => t.name.toLowerCase() === name.toLowerCase(),
);
if (existing) {
if (!value.includes(existing.id)) onChange([...value, existing.id]);
setDraft("");
return;
}
setBusy(true);
setError("");
try {
const id = await createTag(mutate, data, name);
onChange([...latest.current, id]);
setDraft("");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<fieldset className="tag-picker">
<legend>Tags</legend>
{data.tags.length ? (
data.tags.map((tag) => (
<label className="check-chip" key={tag.id}>
<input
type="checkbox"
checked={value.includes(tag.id)}
onChange={(e) =>
onChange(
e.target.checked
? [...value, tag.id]
: value.filter((id) => id !== tag.id),
)
}
/>
{tag.name}
</label>
))
) : (
<legend>{label}</legend>
{data.tags.map((tag) => (
<label className="check-chip" key={tag.id}>
<input
type="checkbox"
checked={value.includes(tag.id)}
onChange={(e) =>
onChange(
e.target.checked
? [...value, tag.id]
: value.filter((id) => id !== tag.id),
)
}
/>
{tag.name}
</label>
))}
{!data.tags.length && !mutate && (
<small>No tags yet. Create them in Tags.</small>
)}
{mutate && (
<span className="tag-add">
<input
value={draft}
maxLength={200}
placeholder="New tag"
aria-label="New tag name"
disabled={busy}
onChange={(e) => {
setDraft(e.target.value);
setError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void add();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Create tag"
disabled={busy || !draft.trim()}
onClick={() => void add()}
>
<Plus size={15} />
</button>
</span>
)}
{error && <small className="tag-add-error">{error}</small>}
</fieldset>
);
}
@@ -517,6 +733,98 @@ export function CategoryOptions({
</>
);
}
// CategoryCombobox is the one category picker: options are full paths, and
// with a mutate handle an unmatched name can be created in place. A bare name
// is offered under each matching root; "Parent / Name" creates under that
// existing parent. leavesOnly matches the server rule that assigned categories
// must be leaves; a freshly created category is always a leaf.
export function CategoryCombobox({
data,
value,
onChange,
mutate,
kind,
leavesOnly = false,
exclude = [],
emptyLabel,
required = false,
disabled = false,
placeholder = "Search categories",
}: {
data: Dataset;
value: string;
onChange: (id: string) => void;
mutate?: Mutate;
kind?: string;
leavesOnly?: boolean;
exclude?: string[];
emptyLabel?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
}) {
const parents = new Set(
data.categories.map((c) => c.parent_id).filter(Boolean),
);
const eligible = (c: Category) =>
(!kind || c.kind === kind) && !exclude.includes(c.id);
const options: ComboOption[] = data.categories
.filter((c) => eligible(c) && (!leavesOnly || !parents.has(c.id)))
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
if (emptyLabel) options.unshift({ value: "", label: emptyLabel });
const pathOf = (id: string) => categoryPath(data, id).toLowerCase();
const taken = (parentID: string, name: string) => {
const full = `${parentID ? pathOf(parentID) + " / " : ""}${name.toLowerCase()}`;
return data.categories.some((c) => pathOf(c.id) === full);
};
const create = (text: string): ComboCreate[] => {
if (!mutate) return [];
const segments = text
.split("/")
.map((s) => s.trim())
.filter(Boolean);
if (!segments.length) return [];
const name = segments[segments.length - 1];
const row = (parent: Category): ComboCreate => ({
key: parent.id,
label: `Create category "${name}" in ${categoryPath(data, parent.id)}`,
run: async () =>
onChange(
await createCategory(mutate, data, {
name,
parent_id: parent.id,
kind: parent.kind,
}),
),
});
if (segments.length > 1) {
const prefix = segments.slice(0, -1).join(" / ").toLowerCase();
const parent = data.categories.find(
(c) => eligible(c) && pathOf(c.id) === prefix,
);
return parent && !taken(parent.id, name) ? [row(parent)] : [];
}
return data.categories
.filter((c) => !c.parent_id && eligible(c) && !taken(c.id, name))
.map(row);
};
return (
<Combobox
options={options}
value={value}
onChange={onChange}
required={required}
disabled={disabled}
placeholder={placeholder}
emptyText={
mutate
? "No match. Type a category name and choose Create category."
: "No matching category."
}
create={create}
/>
);
}
export function Filters({
data,
value,
@@ -526,8 +834,10 @@ export function Filters({
value: Filter;
onChange: (filter: Filter) => void;
}) {
const update = (key: keyof Filter, text: string) =>
onChange({ ...value, [key]: text });
const update = (
key: Exclude<keyof Filter, "tag_ids" | "exclude_tag_ids">,
text: string,
) => onChange({ ...value, [key]: text });
const currencies = Array.from(
new Set([
...data.accounts.map((a) => a.currency),
@@ -546,6 +856,22 @@ export function Filters({
{ label: "YTD", title: "Year to date", from: yearStart(), to: "" },
{ label: "All", title: "All time", from: "", to: "" },
];
const tagFilters = [
{
key: "tag_ids",
opposite: "exclude_tag_ids",
label: "Include tags",
polarity: "Include",
hint: "Match any selected tag; empty includes all.",
},
{
key: "exclude_tag_ids",
opposite: "tag_ids",
label: "Exclude tags",
polarity: "Exclude",
hint: "Hide transactions with any selected tag.",
},
] as const;
return (
<div className="filter-bar">
<div className="range-row">
@@ -625,19 +951,6 @@ export function Filters({
<CategoryOptions data={data} />
</select>
</Field>
<Field label="Tag">
<select
value={value.tag_id}
onChange={(e) => update("tag_id", e.target.value)}
>
<option value="">All tags</option>
{data.tags.map((t) => (
<option value={t.id} key={t.id}>
{t.name}
</option>
))}
</select>
</Field>
<Field label="Merchant">
<select
value={value.merchant_id}
@@ -652,6 +965,58 @@ export function Filters({
</select>
</Field>
</div>
<div className="tag-filters">
{tagFilters.map(({ key, opposite, label, polarity, hint }) => (
<div className="tag-filter" key={key}>
<Field label={label} hint={hint}>
<Combobox
options={data.tags
.filter((tag) => !value[key].includes(tag.id))
.map((tag) => ({ value: tag.id, label: tag.name }))}
value=""
placeholder={`Add tag to ${polarity.toLowerCase()}`}
emptyText="No more matching tags."
onChange={(id) =>
onChange({
...value,
[key]: value[key].includes(id)
? value[key]
: [...value[key], id],
[opposite]: value[opposite].filter((tag) => tag !== id),
})
}
/>
</Field>
{value[key].length > 0 && (
<div className="tag-edit" role="group" aria-label={label}>
{value[key].map((id) => {
const name =
data.tags.find((tag) => tag.id === id)?.name || id;
return (
<button
type="button"
className={`tag-chip ${key === "exclude_tag_ids" ? "excluded" : ""}`}
key={id}
aria-label={`Remove ${name} from ${polarity.toLowerCase()} tags`}
onClick={() =>
onChange({
...value,
[key]: value[key].filter((tag) => tag !== id),
})
}
>
<span>
{polarity}: {name}
</span>
<X size={12} aria-hidden="true" />
</button>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
}
@@ -680,8 +1045,10 @@ export function FormActions({
</div>
);
}
// Mutate posts a revisioned change and returns the accepted state, so a
// caller can find ids the server just minted.
export type Mutate = (
path: string,
body: Record<string, unknown>,
message?: string,
) => Promise<void>;
) => Promise<State>;