Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e02d95cb | ||
|
|
77f4ea5655 |
+19
-9
@@ -147,8 +147,14 @@ this is not local AI and cannot promise that a remote provider honors policy.
|
|||||||
|
|
||||||
Each classification sends the transaction date, signed amount, currency,
|
Each classification sends the transaction date, signed amount, currency,
|
||||||
merchant and counterparty text, account institution/currency, the complete
|
merchant and counterparty text, account institution/currency, the complete
|
||||||
leaf-category registry for the transaction kind, all tags and all merchants
|
leaf-category registry for the transaction kind, all tags and all merchants.
|
||||||
with their real local IDs. Identifier-only redaction removes IBANs (with a
|
Names, paths, hints and aliases remain available, but registry IDs use short
|
||||||
|
request-local references (c1, m1, t1), including merchant usual categories and
|
||||||
|
history. History includes only categories offered for that transaction kind.
|
||||||
|
Responses are mapped back to canonical IDs and validated locally; canonical
|
||||||
|
IDs are not accepted as alternative response references. This keeps the full
|
||||||
|
registry without the long-ID schema overhead that providers can reject.
|
||||||
|
Identifier-only redaction removes IBANs (with a
|
||||||
directly attached BIC), labeled BIC/SWIFT references, UUIDs, URLs/emails,
|
directly attached BIC), labeled BIC/SWIFT references, UUIDs, URLs/emails,
|
||||||
labeled payment or customer references, card fragments, long digit-bearing
|
labeled payment or customer references, card fragments, long digit-bearing
|
||||||
tokens, the row's own IDs, account labels and configured private names. A
|
tokens, the row's own IDs, account labels and configured private names. A
|
||||||
@@ -536,10 +542,13 @@ repeats daily. Nothing is committed when no quote changed.
|
|||||||
|
|
||||||
A holding's value is its share count times its quote, rounded half away from
|
A holding's value is its share count times its quote, rounded half away from
|
||||||
zero to money's four places. Positions is that value summed per account, wealth
|
zero to money's four places. Positions is that value summed per account, wealth
|
||||||
is cash plus positions, and result is value plus everything the position
|
is cash plus positions plus hand-valued assets, and result is value plus
|
||||||
returned less everything put into it - the outcome to date, realised and not.
|
everything the position returned less everything put into it - the outcome to
|
||||||
None of these figures are read from the DuckDB index: the report is recomputed
|
date, realised and not. A hand-valued asset (a house, a car, a private loan) is
|
||||||
from the journal so it can be checked against a broker's own screen.
|
entered on the Wealth page with a stated value, a currency and the day the
|
||||||
|
estimate was made; a negative value records a liability. None of these figures
|
||||||
|
are read from the DuckDB index: the report is recomputed from the journal so it
|
||||||
|
can be checked against a broker's own screen.
|
||||||
|
|
||||||
A broker reuses one reference across every leg of an economic event: the cash
|
A broker reuses one reference across every leg of an economic event: the cash
|
||||||
and position sides of a corporate action arrive with the same reference byte for
|
and position sides of a corporate action arrive with the same reference byte for
|
||||||
@@ -652,6 +661,7 @@ finance/
|
|||||||
tags.finance
|
tags.finance
|
||||||
merchants.finance
|
merchants.finance
|
||||||
instruments.finance
|
instruments.finance
|
||||||
|
assets.finance
|
||||||
journal/YYYY/YYYY-MM.finance
|
journal/YYYY/YYYY-MM.finance
|
||||||
state/sync-state.json sensitive local consent/session metadata
|
state/sync-state.json sensitive local consent/session metadata
|
||||||
state/openrouter.json sensitive UI-managed OpenRouter key or explicit disable
|
state/openrouter.json sensitive UI-managed OpenRouter key or explicit disable
|
||||||
@@ -738,9 +748,9 @@ recurring payees classify locally without any provider request.
|
|||||||
The finished run is a read-only preview. Apply all/selected writes all
|
The finished run is a read-only preview. Apply all/selected writes all
|
||||||
selected changes in one canonical commit; financial facts never change. A
|
selected changes in one canonical commit; financial facts never change. A
|
||||||
manual edit, external journal change or taxonomy change invalidates old previews.
|
manual edit, external journal change or taxonomy change invalidates old previews.
|
||||||
Previews are kept in memory for up to one hour and disappear on restart. Cancel
|
Previews are kept in memory for up to 24 hours from the start of analysis and
|
||||||
writes nothing. Transfers and broker facts are skipped, and unselected fields
|
disappear on restart. Cancel writes nothing. Transfers and broker facts are
|
||||||
are preserved.
|
skipped, and unselected fields are preserved.
|
||||||
When a selected transaction is linked to a merchant, applying the preview and
|
When a selected transaction is linked to a merchant, applying the preview and
|
||||||
manual transaction edits may add its normalized counterparty as an alias if
|
manual transaction edits may add its normalized counterparty as an alias if
|
||||||
that alias is unambiguous and the merchant has fewer than 32 aliases. A new
|
that alias is unambiguous and the merchant has fewer than 32 aliases. A new
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ One ISIN lists on several exchanges in different currencies, and the wrong listi
|
|||||||
|
|
||||||
**Verify it yourself.** **Wealth** shows each account's cash, its positions as exact share counts, each holding's quote, value and result, and named checks — row arithmetic, cash never negative, holdings never negative, holdings priced. Compare the cash balance and the positions against your broker's own screen. The figures come from the journal, not from the DuckDB index, so they do not depend on the cache that the same journal derives. A negative holding means the imported history is partial: a position was closed that was never opened.
|
**Verify it yourself.** **Wealth** shows each account's cash, its positions as exact share counts, each holding's quote, value and result, and named checks — row arithmetic, cash never negative, holdings never negative, holdings priced. Compare the cash balance and the positions against your broker's own screen. The figures come from the journal, not from the DuckDB index, so they do not depend on the cache that the same journal derives. A negative holding means the imported history is partial: a position was closed that was never opened.
|
||||||
|
|
||||||
|
**Other assets.** Possessions with no market feed — a house, a car, a private loan — are added by hand on the **Wealth** page with a stated value, a currency and the day the estimate was made, and they join the total immediately. A negative value records a liability such as a mortgage. Each asset is a plaintext block in `assets.finance` like every other registry entity, so a backup carries it and a text editor can correct it. The value is never guessed or aged: it stays what you stated, dated, until you re-edit it.
|
||||||
|
|
||||||
Deliberately **not** included: intraday prices, net worth over time, FIFO lot accounting, realised gains, `Vorabpauschale`, and currency conversion. A position's *invested* figure is cash in less cash out, not a cost basis, and *result* is value plus everything returned less everything put in — the outcome to date, not a taxable gain.
|
Deliberately **not** included: intraday prices, net worth over time, FIFO lot accounting, realised gains, `Vorabpauschale`, and currency conversion. A position's *invested* figure is cash in less cash out, not a cost basis, and *result* is value plus everything returned less everything put in — the outcome to date, not a taxable gain.
|
||||||
|
|
||||||
## Deployment options
|
## Deployment options
|
||||||
@@ -423,6 +425,8 @@ Bank synchronization and recognized N26, ING, and Kontist CSV imports do **not**
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
From **Categories**, **Propose taxonomy** samples up to 300 redacted transactions, grouped so recurring counterparties are represented without sending raw identifiers. The proposal can suggest categories, tags, and merchants with hints and evidence. Approve each item individually; applying it also creates any approved category parents required by the hierarchy. Existing registry entries and transaction facts are never overwritten.
|
From **Categories**, **Propose taxonomy** samples up to 300 redacted transactions, grouped so recurring counterparties are represented without sending raw identifiers. The proposal can suggest categories, tags, and merchants with hints and evidence. Approve each item individually; applying it also creates any approved category parents required by the hierarchy. Existing registry entries and transaction facts are never overwritten.
|
||||||
|
|
||||||
Every AI classification requests `provider.data_collection = "deny"`, `provider.zdr = true`, and `provider.require_parameters = true`. Unsupported private routing fails rather than falling back to a less restrictive provider. Amount, date, and currency are always included; identifier-only redaction removes account and transaction identifiers, payment references, and configured private names but does not remove merchant or counterparty text. Keep OpenRouter account prompt logging disabled as well.
|
Every AI classification requests `provider.data_collection = "deny"`, `provider.zdr = true`, and `provider.require_parameters = true`. Unsupported private routing fails rather than falling back to a less restrictive provider. Amount, date, and currency are always included; identifier-only redaction removes account and transaction identifiers, payment references, and configured private names but does not remove merchant or counterparty text. Keep OpenRouter account prompt logging disabled as well.
|
||||||
|
|||||||
@@ -360,6 +360,65 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
|||||||
t.Fatal("consumed preview applied twice")
|
t.Fatal("consumed preview applied twice")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
age time.Duration
|
||||||
|
newPreview bool
|
||||||
|
expired bool
|
||||||
|
}{
|
||||||
|
{name: "apply before expiry", age: 24*time.Hour - time.Minute},
|
||||||
|
{name: "apply after expiry", age: 24*time.Hour + time.Minute, expired: true},
|
||||||
|
{name: "new preview retains unexpired review", age: 24*time.Hour - time.Minute, newPreview: true},
|
||||||
|
{name: "new preview discards expired review", age: 24*time.Hour + time.Minute, newPreview: true, expired: true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
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}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(p.Changes) != 2 {
|
||||||
|
t.Fatalf("expected two proposed changes: %+v", p)
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
p.created = time.Now().Add(-tc.age)
|
||||||
|
a.previews[p.ID] = p
|
||||||
|
a.mu.Unlock()
|
||||||
|
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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
change := p.Changes[0]
|
||||||
|
_, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{change.ID}, nil)
|
||||||
|
if (err != nil) != tc.expired {
|
||||||
|
t.Fatalf("apply at age %s: error = %v, expired = %t", tc.age, err, tc.expired)
|
||||||
|
}
|
||||||
|
after, err := a.Snapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := domain.Clone(s.Data)
|
||||||
|
if !tc.expired {
|
||||||
|
for i := range expected.Transactions {
|
||||||
|
if expected.Transactions[i].Facts.ID == change.ID {
|
||||||
|
expected.Transactions[i].Enrichment = change.After
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(after.Data, expected) {
|
||||||
|
t.Fatal("expiry handling did not preserve the expected transaction state")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
||||||
a, s := testApp(t)
|
a, s := testApp(t)
|
||||||
s = seed(t, a, s)
|
s = seed(t, a, s)
|
||||||
@@ -491,7 +550,7 @@ func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
|
|||||||
func TestImportNeverAutoAppliesLowConfidenceCategory(t *testing.T) {
|
func TestImportNeverAutoAppliesLowConfidenceCategory(t *testing.T) {
|
||||||
a, s := testApp(t)
|
a, s := testApp(t)
|
||||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
content := `{"merchant_id":null,"new_merchant":"REWE","category_id":"groceries","tag_ids":[],"confidence":"low"}`
|
content := `{"merchant_id":null,"new_merchant":"REWE","category_id":"c1","tag_ids":[],"confidence":"low"}`
|
||||||
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{
|
||||||
"finish_reason": "stop",
|
"finish_reason": "stop",
|
||||||
"message": map[string]any{"content": content},
|
"message": map[string]any{"content": content},
|
||||||
|
|||||||
@@ -104,6 +104,25 @@ func SaveInstrument(d *domain.Dataset, v domain.Instrument) error {
|
|||||||
d.Instruments = append(d.Instruments, v)
|
d.Instruments = append(d.Instruments, v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveAsset registers or revalues a hand-valued possession. The value and the
|
||||||
|
// day it was stated travel together; full validation happens at commit.
|
||||||
|
func SaveAsset(d *domain.Dataset, v domain.Asset) error {
|
||||||
|
v.Name = strings.TrimSpace(v.Name)
|
||||||
|
v.Kind = strings.TrimSpace(v.Kind)
|
||||||
|
v.Currency = strings.ToUpper(strings.TrimSpace(v.Currency))
|
||||||
|
if v.ID == "" {
|
||||||
|
v.ID = domain.NewID("asset")
|
||||||
|
}
|
||||||
|
for i, x := range d.Assets {
|
||||||
|
if x.ID == v.ID {
|
||||||
|
d.Assets[i] = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.Assets = append(d.Assets, v)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func SaveCategory(d *domain.Dataset, v domain.Category) error {
|
func SaveCategory(d *domain.Dataset, v domain.Category) error {
|
||||||
v.Name = strings.TrimSpace(v.Name)
|
v.Name = strings.TrimSpace(v.Name)
|
||||||
if v.ID == "" {
|
if v.ID == "" {
|
||||||
@@ -203,6 +222,15 @@ func Manage(d *domain.Dataset, entity, action, id, target string) error {
|
|||||||
if n == len(d.Instruments) {
|
if n == len(d.Instruments) {
|
||||||
return errors.New("unknown instrument")
|
return errors.New("unknown instrument")
|
||||||
}
|
}
|
||||||
|
case "asset":
|
||||||
|
if action != "delete" {
|
||||||
|
return errors.New("asset merging is not supported")
|
||||||
|
}
|
||||||
|
n := len(d.Assets)
|
||||||
|
d.Assets = slices.DeleteFunc(d.Assets, func(v domain.Asset) bool { return v.ID == id })
|
||||||
|
if n == len(d.Assets) {
|
||||||
|
return errors.New("unknown asset")
|
||||||
|
}
|
||||||
case "tag":
|
case "tag":
|
||||||
if !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) {
|
if !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) {
|
||||||
return errors.New("unknown tag")
|
return errors.New("unknown tag")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"finance-duck/internal/domain"
|
"finance-duck/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const previewLifetime = 24 * time.Hour
|
||||||
|
|
||||||
type Fields struct {
|
type Fields struct {
|
||||||
Merchant bool `json:"merchant"`
|
Merchant bool `json:"merchant"`
|
||||||
Category bool `json:"category"`
|
Category bool `json:"category"`
|
||||||
@@ -173,7 +175,7 @@ func (a *App) runPreview(ctx context.Context, cancel context.CancelFunc, client
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for id, old := range a.previews {
|
for id, old := range a.previews {
|
||||||
if time.Since(old.created) > time.Hour {
|
if time.Since(old.created) > previewLifetime {
|
||||||
delete(a.previews, id)
|
delete(a.previews, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,7 +345,7 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string, ed
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
p, ok := a.previews[id]
|
p, ok := a.previews[id]
|
||||||
if !ok || time.Since(p.created) > time.Hour {
|
if !ok || time.Since(p.created) > previewLifetime {
|
||||||
return State{}, errors.New("preview expired or unknown; analyse again")
|
return State{}, errors.New("preview expired or unknown; analyse again")
|
||||||
}
|
}
|
||||||
if rev != p.Revision {
|
if rev != p.Revision {
|
||||||
|
|||||||
+45
-7
@@ -15,8 +15,11 @@ import (
|
|||||||
// same journal derives.
|
// same journal derives.
|
||||||
type Wealth struct {
|
type Wealth struct {
|
||||||
Accounts []WealthAccount `json:"accounts"`
|
Accounts []WealthAccount `json:"accounts"`
|
||||||
// Totals is cash, position value and their sum per currency, across every
|
// Assets are the hand-valued possessions outside any account, echoed here
|
||||||
// account.
|
// so the page that shows the total also shows what the total contains.
|
||||||
|
Assets []WealthAsset `json:"assets"`
|
||||||
|
// Totals is cash, position value, hand-valued assets and their sum per
|
||||||
|
// currency, across every account.
|
||||||
Totals []WealthTotal `json:"totals"`
|
Totals []WealthTotal `json:"totals"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@ type WealthTotal struct {
|
|||||||
// in Unpriced, because valuing them at cost would report a number the
|
// in Unpriced, because valuing them at cost would report a number the
|
||||||
// journal cannot support.
|
// journal cannot support.
|
||||||
Positions domain.Money `json:"positions"`
|
Positions domain.Money `json:"positions"`
|
||||||
|
// Assets is the stated value of every hand-valued asset in this currency,
|
||||||
|
// and Wealth is cash, positions and assets together.
|
||||||
|
Assets domain.Money `json:"assets"`
|
||||||
Wealth domain.Money `json:"wealth"`
|
Wealth domain.Money `json:"wealth"`
|
||||||
Unpriced int `json:"unpriced"`
|
Unpriced int `json:"unpriced"`
|
||||||
}
|
}
|
||||||
@@ -113,6 +119,17 @@ type WealthHolding struct {
|
|||||||
Records int `json:"records"`
|
Records int `json:"records"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WealthAsset is one hand-valued asset as the journal records it. The value is
|
||||||
|
// stated, never quoted, and carries the day it was stated.
|
||||||
|
type WealthAsset struct {
|
||||||
|
AssetID string `json:"asset_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Value domain.Money `json:"value"`
|
||||||
|
ValuedAt string `json:"valued_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// WealthCheck is one named verification with its evidence. Failed marks a
|
// WealthCheck is one named verification with its evidence. Failed marks a
|
||||||
// disagreement inside the journal; the rest are notes that explain a figure
|
// disagreement inside the journal; the rest are notes that explain a figure
|
||||||
// before it is compared with a broker's screen.
|
// before it is compared with a broker's screen.
|
||||||
@@ -282,11 +299,18 @@ func WealthOf(data domain.Dataset) Wealth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
report := Wealth{Accounts: []WealthAccount{}, Totals: []WealthTotal{}}
|
report := Wealth{Accounts: []WealthAccount{}, Assets: []WealthAsset{}, Totals: []WealthTotal{}}
|
||||||
totals := map[string]int64{}
|
totals := map[string]int64{}
|
||||||
positionTotals := map[string]int64{}
|
positionTotals := map[string]int64{}
|
||||||
|
assetTotals := map[string]int64{}
|
||||||
unpricedTotals := map[string]int{}
|
unpricedTotals := map[string]int{}
|
||||||
currencies := []string{}
|
currencies := []string{}
|
||||||
|
seen := func(currency string) {
|
||||||
|
if _, ok := totals[currency]; !ok {
|
||||||
|
currencies = append(currencies, currency)
|
||||||
|
totals[currency] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, account := range data.Accounts {
|
for _, account := range data.Accounts {
|
||||||
st := state(account.ID)
|
st := state(account.ID)
|
||||||
kind := account.Kind
|
kind := account.Kind
|
||||||
@@ -308,9 +332,7 @@ func WealthOf(data domain.Dataset) Wealth {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, seen := totals[account.Currency]; !seen {
|
seen(account.Currency)
|
||||||
currencies = append(currencies, account.Currency)
|
|
||||||
}
|
|
||||||
totals[account.Currency] += st.cash
|
totals[account.Currency] += st.cash
|
||||||
positions, unpriced, stale := int64(0), 0, []string{}
|
positions, unpriced, stale := int64(0), 0, []string{}
|
||||||
for _, id := range st.order {
|
for _, id := range st.order {
|
||||||
@@ -391,11 +413,27 @@ func WealthOf(data domain.Dataset) Wealth {
|
|||||||
}
|
}
|
||||||
report.Accounts = append(report.Accounts, entry)
|
report.Accounts = append(report.Accounts, entry)
|
||||||
}
|
}
|
||||||
|
// Hand-valued assets join the totals after the accounts: they belong to no
|
||||||
|
// account, and a currency held only in an asset still earns its own line.
|
||||||
|
for _, asset := range data.Assets {
|
||||||
|
value, err := asset.Value.Minor()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen(asset.Currency)
|
||||||
|
assetTotals[asset.Currency] += value
|
||||||
|
report.Assets = append(report.Assets, WealthAsset{
|
||||||
|
AssetID: asset.ID, Name: asset.Name, Kind: asset.Kind,
|
||||||
|
Currency: asset.Currency, Value: domain.FormatMoney(value), ValuedAt: asset.ValuedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(report.Assets, func(x, y WealthAsset) int { return strings.Compare(x.Name, y.Name) })
|
||||||
for _, currency := range currencies {
|
for _, currency := range currencies {
|
||||||
report.Totals = append(report.Totals, WealthTotal{
|
report.Totals = append(report.Totals, WealthTotal{
|
||||||
Currency: currency, Cash: domain.FormatMoney(totals[currency]),
|
Currency: currency, Cash: domain.FormatMoney(totals[currency]),
|
||||||
Positions: domain.FormatMoney(positionTotals[currency]),
|
Positions: domain.FormatMoney(positionTotals[currency]),
|
||||||
Wealth: domain.FormatMoney(totals[currency] + positionTotals[currency]),
|
Assets: domain.FormatMoney(assetTotals[currency]),
|
||||||
|
Wealth: domain.FormatMoney(totals[currency] + positionTotals[currency] + assetTotals[currency]),
|
||||||
Unpriced: unpricedTotals[currency],
|
Unpriced: unpricedTotals[currency],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,3 +381,38 @@ func TestWealthValuesHoldingsAtTheirQuote(t *testing.T) {
|
|||||||
t.Error("no note about the holdings left out of the wealth figure")
|
t.Error("no note about the holdings left out of the wealth figure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A wealth figure that ignores the house is not a wealth figure. A hand-valued
|
||||||
|
// asset joins its currency's total, a currency held only in an asset earns its
|
||||||
|
// own line, and a negative value records a liability that subtracts.
|
||||||
|
func TestWealthCountsHandValuedAssets(t *testing.T) {
|
||||||
|
data := domain.NewDataset()
|
||||||
|
data.Accounts = []domain.Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}}
|
||||||
|
f := domain.Facts{
|
||||||
|
ID: "tx_1", Source: "csv", AccountID: "acc_main", BookingDate: "2026-01-02",
|
||||||
|
Amount: "1000.00", Currency: "EUR", RawDescription: "salary", Fingerprint: "tx_1",
|
||||||
|
}
|
||||||
|
data.Transactions = []domain.Transaction{{Facts: f, Enrichment: domain.Fallback(f)}}
|
||||||
|
data.Assets = []domain.Asset{
|
||||||
|
{ID: "asset_house", Name: "House", Kind: "Real estate", Currency: "EUR", Value: "250000.00", ValuedAt: "2026-09-01"},
|
||||||
|
{ID: "asset_loan", Name: "Mortgage", Currency: "EUR", Value: "-150000.00", ValuedAt: "2026-09-01"},
|
||||||
|
{ID: "asset_cabin", Name: "Cabin", Currency: "USD", Value: "40000.00", ValuedAt: "2026-08-15"},
|
||||||
|
}
|
||||||
|
if err := domain.Validate(data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report := WealthOf(data)
|
||||||
|
byCurrency := map[string]WealthTotal{}
|
||||||
|
for _, total := range report.Totals {
|
||||||
|
byCurrency[total.Currency] = total
|
||||||
|
}
|
||||||
|
if eur := byCurrency["EUR"]; eur.Cash != "1000.00" || eur.Assets != "100000.00" || eur.Wealth != "101000.00" {
|
||||||
|
t.Errorf("EUR total %+v; want cash 1000.00, assets 100000.00, wealth 101000.00", eur)
|
||||||
|
}
|
||||||
|
if usd, ok := byCurrency["USD"]; !ok || usd.Cash != "0.00" || usd.Assets != "40000.00" || usd.Wealth != "40000.00" {
|
||||||
|
t.Errorf("a currency held only in an asset earned no line of its own: %+v", byCurrency["USD"])
|
||||||
|
}
|
||||||
|
if len(report.Assets) != 3 || report.Assets[0].Name != "Cabin" || report.Assets[1].ValuedAt != "2026-09-01" {
|
||||||
|
t.Errorf("assets not echoed sorted by name with their dates: %+v", report.Assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func (c *Client) ClassifyBatch(ctx context.Context, rows []domain.Facts, data do
|
|||||||
payload.Transactions = append(payload.Transactions, row)
|
payload.Transactions = append(payload.Transactions, row)
|
||||||
similar.WriteString(f.RawDescription + " " + f.Counterparty + " ")
|
similar.WriteString(f.RawDescription + " " + f.Counterparty + " ")
|
||||||
}
|
}
|
||||||
payload.History = history(domain.Facts{RawDescription: similar.String()}, data, clean, 40)
|
payload.History = candidates.history(domain.Facts{RawDescription: similar.String()}, data, clean, 40)
|
||||||
payload.Categories = candidates.categories
|
payload.Categories = candidates.categories
|
||||||
payload.Tags = candidates.tags
|
payload.Tags = candidates.tags
|
||||||
payload.Merchants = candidates.merchants
|
payload.Merchants = candidates.merchants
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package classification
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,28 +31,34 @@ func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) {
|
|||||||
calls := 0
|
calls := 0
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
calls++
|
calls++
|
||||||
var req struct {
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
Messages []struct {
|
if len(prompt.Transactions) != 2 {
|
||||||
Content string `json:"content"`
|
t.Errorf("batch prompt missing transactions: %+v", prompt.Transactions)
|
||||||
} `json:"messages"`
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
}
|
|
||||||
if json.NewDecoder(r.Body).Decode(&req) != nil || len(req.Messages) != 2 {
|
|
||||||
w.WriteHeader(400)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var prompt struct {
|
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
|
||||||
Transactions []struct{ Ref, Counterparty, Amount, Currency string } `json:"transactions"`
|
merchant, tag := "", ""
|
||||||
|
for _, candidate := range prompt.Merchants {
|
||||||
|
if candidate.Name == "coffee house" {
|
||||||
|
merchant = candidate.ID
|
||||||
}
|
}
|
||||||
if json.Unmarshal([]byte(req.Messages[1].Content), &prompt) != nil || len(prompt.Transactions) != 2 {
|
}
|
||||||
t.Errorf("batch prompt missing transactions: %s", req.Messages[1].Content)
|
for _, candidate := range prompt.Tags {
|
||||||
|
if candidate.Name == "daily" {
|
||||||
|
tag = candidate.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if merchant == "" || tag == "" {
|
||||||
|
t.Error("batch prompt lost Coffee House or Daily")
|
||||||
}
|
}
|
||||||
for _, row := range prompt.Transactions {
|
for _, row := range prompt.Transactions {
|
||||||
if row.Amount == "" || row.Currency != "EUR" {
|
if row.Amount == "" || row.Currency != "EUR" {
|
||||||
t.Errorf("row %s lost amount or currency: %+v", row.Ref, row)
|
t.Errorf("row %s lost amount or currency: %+v", row.Ref, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reply(w, `{"transactions":[{"ref":"r1","merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"},`+
|
reply(w, `{"transactions":[{"ref":"`+prompt.Transactions[1].Ref+`","merchant_id":null,"new_merchant":"Kleins Backstube","category_id":"`+category+`","tag_ids":[],"confidence":"medium"},`+
|
||||||
`{"ref":"r2","merchant_id":null,"new_merchant":"Kleins Backstube","category_id":"cat_food","tag_ids":[],"confidence":"medium"}]}`)
|
`{"ref":"`+prompt.Transactions[0].Ref+`","merchant_id":"`+merchant+`","new_merchant":null,"category_id":"`+category+`","tag_ids":["`+tag+`"],"confidence":"high"}]}`)
|
||||||
})
|
})
|
||||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||||
if calls != 1 {
|
if calls != 1 {
|
||||||
@@ -71,6 +76,8 @@ func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) {
|
|||||||
if second.NewMerchant == nil || second.NewMerchant.Name != "Kleins Backstube" ||
|
if second.NewMerchant == nil || second.NewMerchant.Name != "Kleins Backstube" ||
|
||||||
!reflect.DeepEqual(second.NewMerchant.Aliases, []string{"Kleins Backstube"}) ||
|
!reflect.DeepEqual(second.NewMerchant.Aliases, []string{"Kleins Backstube"}) ||
|
||||||
second.Enrichment.MerchantID != second.NewMerchant.ID ||
|
second.Enrichment.MerchantID != second.NewMerchant.ID ||
|
||||||
|
second.Enrichment.CategoryID != "cat_food" ||
|
||||||
|
len(second.Enrichment.TagIDs) != 0 ||
|
||||||
second.Enrichment.Classification.Confidence != "medium" {
|
second.Enrichment.Classification.Confidence != "medium" {
|
||||||
t.Fatalf("second row lost: %+v", second)
|
t.Fatalf("second row lost: %+v", second)
|
||||||
}
|
}
|
||||||
@@ -80,8 +87,10 @@ func TestBatchClassifiesEveryRowInOneRequest(t *testing.T) {
|
|||||||
func TestBatchIsolatesInvalidRows(t *testing.T) {
|
func TestBatchIsolatesInvalidRows(t *testing.T) {
|
||||||
f1, f2, d := batchRows()
|
f1, f2, d := batchRows()
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
reply(w, `{"transactions":[{"ref":"r1","merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"},`+
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
`{"ref":"r2","merchant_id":null,"new_merchant":null,"category_id":"cat_forged","tag_ids":[],"confidence":"high"}]}`)
|
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
|
||||||
|
reply(w, `{"transactions":[{"ref":"`+prompt.Transactions[0].Ref+`","merchant_id":null,"new_merchant":null,"category_id":"`+category+`","tag_ids":[],"confidence":"high"},`+
|
||||||
|
`{"ref":"`+prompt.Transactions[1].Ref+`","merchant_id":null,"new_merchant":null,"category_id":"c999999","tag_ids":[],"confidence":"high"}]}`)
|
||||||
})
|
})
|
||||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||||
if results[0].Err != nil || results[0].Proposal.Enrichment.CategoryID != "cat_food" {
|
if results[0].Err != nil || results[0].Proposal.Enrichment.CategoryID != "cat_food" {
|
||||||
@@ -96,8 +105,10 @@ func TestBatchIsolatesInvalidRows(t *testing.T) {
|
|||||||
func TestBatchSharesOneMintedMerchant(t *testing.T) {
|
func TestBatchSharesOneMintedMerchant(t *testing.T) {
|
||||||
f1, f2, d := batchRows()
|
f1, f2, d := batchRows()
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
reply(w, `{"transactions":[{"ref":"r1","merchant_id":null,"new_merchant":"REWE","category_id":"cat_food","tag_ids":[],"confidence":"high"},`+
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
`{"ref":"r2","merchant_id":null,"new_merchant":"REWE","category_id":"cat_food","tag_ids":[],"confidence":"high"}]}`)
|
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
|
||||||
|
reply(w, `{"transactions":[{"ref":"`+prompt.Transactions[0].Ref+`","merchant_id":null,"new_merchant":"REWE","category_id":"`+category+`","tag_ids":[],"confidence":"high"},`+
|
||||||
|
`{"ref":"`+prompt.Transactions[1].Ref+`","merchant_id":null,"new_merchant":"REWE","category_id":"`+category+`","tag_ids":[],"confidence":"high"}]}`)
|
||||||
})
|
})
|
||||||
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
results := c.ClassifyBatch(context.Background(), []domain.Facts{f1, f2}, d)
|
||||||
if results[0].Err != nil || results[1].Err != nil {
|
if results[0].Err != nil || results[1].Err != nil {
|
||||||
@@ -144,19 +155,8 @@ func TestBatchSplitsOnProviderSchemaRejection(t *testing.T) {
|
|||||||
calls, oversized := 0, 0
|
calls, oversized := 0, 0
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
calls++
|
calls++
|
||||||
var req struct {
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
Messages []struct {
|
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"messages"`
|
|
||||||
}
|
|
||||||
if json.NewDecoder(r.Body).Decode(&req) != nil {
|
|
||||||
w.WriteHeader(500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var prompt struct {
|
|
||||||
Transactions []struct{ Ref string } `json:"transactions"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal([]byte(req.Messages[1].Content), &prompt)
|
|
||||||
if len(prompt.Transactions) > 2 {
|
if len(prompt.Transactions) > 2 {
|
||||||
oversized++
|
oversized++
|
||||||
w.WriteHeader(400)
|
w.WriteHeader(400)
|
||||||
@@ -164,7 +164,7 @@ func TestBatchSplitsOnProviderSchemaRejection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
answers := make([]string, 0, len(prompt.Transactions))
|
answers := make([]string, 0, len(prompt.Transactions))
|
||||||
for _, row := range prompt.Transactions {
|
for _, row := range prompt.Transactions {
|
||||||
answers = append(answers, `{"ref":"`+row.Ref+`","merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`)
|
answers = append(answers, `{"ref":"`+row.Ref+`","merchant_id":null,"new_merchant":null,"category_id":"`+category+`","tag_ids":[],"confidence":"high"}`)
|
||||||
}
|
}
|
||||||
reply(w, `{"transactions":[`+strings.Join(answers, ",")+`]}`)
|
reply(w, `{"transactions":[`+strings.Join(answers, ",")+`]}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package classification
|
|||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -150,8 +151,6 @@ type merchantPrompt struct {
|
|||||||
UsualCategory string `json:"usual_category,omitempty"`
|
UsualCategory string `json:"usual_category,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// candidate is the historical merchant prompt shape used by older callers.
|
|
||||||
type candidate = merchantPrompt
|
|
||||||
type promptHistory struct {
|
type promptHistory struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Amount string `json:"amount"`
|
Amount string `json:"amount"`
|
||||||
@@ -172,6 +171,9 @@ type candidateSet struct {
|
|||||||
categoryIDs map[string]string
|
categoryIDs map[string]string
|
||||||
tagIDs map[string]string
|
tagIDs map[string]string
|
||||||
merchantIDs map[string]string
|
merchantIDs map[string]string
|
||||||
|
categoryRefs map[string]string
|
||||||
|
tagRefs map[string]string
|
||||||
|
merchantRefs map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func similarity(description, name string) int {
|
func similarity(description, name string) int {
|
||||||
@@ -194,9 +196,8 @@ func similarity(description, name string) int {
|
|||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieve emits every registry entry with its real id. The legacy cleaner
|
// retrieve offers every eligible registry entry under a short request-local
|
||||||
// arguments remain in the signature because CSV/classification fixtures use
|
// reference. Names and paths retain their meaning; canonical IDs stay local.
|
||||||
// this helper directly; ranking and bounding are intentionally gone.
|
|
||||||
func retrieve(_ string, kind string, data domain.Dataset, clean, merchantClean func(string) string) candidateSet {
|
func retrieve(_ string, kind string, data domain.Dataset, clean, merchantClean func(string) string) candidateSet {
|
||||||
parents := map[string]bool{}
|
parents := map[string]bool{}
|
||||||
for _, cat := range data.Categories {
|
for _, cat := range data.Categories {
|
||||||
@@ -206,6 +207,9 @@ func retrieve(_ string, kind string, data domain.Dataset, clean, merchantClean f
|
|||||||
categoryIDs: map[string]string{},
|
categoryIDs: map[string]string{},
|
||||||
tagIDs: map[string]string{},
|
tagIDs: map[string]string{},
|
||||||
merchantIDs: map[string]string{},
|
merchantIDs: map[string]string{},
|
||||||
|
categoryRefs: map[string]string{},
|
||||||
|
tagRefs: map[string]string{},
|
||||||
|
merchantRefs: map[string]string{},
|
||||||
}
|
}
|
||||||
for _, cat := range data.Categories {
|
for _, cat := range data.Categories {
|
||||||
if cat.Kind != kind || parents[cat.ID] {
|
if cat.Kind != kind || parents[cat.ID] {
|
||||||
@@ -216,19 +220,31 @@ func retrieve(_ string, kind string, data domain.Dataset, clean, merchantClean f
|
|||||||
path = clean(path)
|
path = clean(path)
|
||||||
}
|
}
|
||||||
set.categories = append(set.categories, categoryPrompt{ID: cat.ID, Path: path, Kind: cat.Kind, Hint: cleanText(clean, cat.Hint)})
|
set.categories = append(set.categories, categoryPrompt{ID: cat.ID, Path: path, Kind: cat.Kind, Hint: cleanText(clean, cat.Hint)})
|
||||||
set.categoryIDs[cat.ID] = cat.ID
|
|
||||||
}
|
}
|
||||||
sort.Slice(set.categories, func(i, j int) bool {
|
sort.Slice(set.categories, func(i, j int) bool {
|
||||||
return set.categories[i].Path < set.categories[j].Path || set.categories[i].Path == set.categories[j].Path && set.categories[i].ID < set.categories[j].ID
|
return set.categories[i].Path < set.categories[j].Path || set.categories[i].Path == set.categories[j].Path && set.categories[i].ID < set.categories[j].ID
|
||||||
})
|
})
|
||||||
|
for i := range set.categories {
|
||||||
|
category := &set.categories[i]
|
||||||
|
ref := "c" + strconv.Itoa(i+1)
|
||||||
|
set.categoryIDs[ref] = category.ID
|
||||||
|
set.categoryRefs[category.ID] = ref
|
||||||
|
category.ID = ref
|
||||||
|
}
|
||||||
for _, tag := range data.Tags {
|
for _, tag := range data.Tags {
|
||||||
name := cleanText(clean, tag.Name)
|
name := cleanText(clean, tag.Name)
|
||||||
set.tags = append(set.tags, tagPrompt{ID: tag.ID, Name: name, Hint: cleanText(clean, tag.Hint)})
|
set.tags = append(set.tags, tagPrompt{ID: tag.ID, Name: name, Hint: cleanText(clean, tag.Hint)})
|
||||||
set.tagIDs[tag.ID] = tag.ID
|
|
||||||
}
|
}
|
||||||
sort.Slice(set.tags, func(i, j int) bool {
|
sort.Slice(set.tags, func(i, j int) bool {
|
||||||
return set.tags[i].Name < set.tags[j].Name || set.tags[i].Name == set.tags[j].Name && set.tags[i].ID < set.tags[j].ID
|
return set.tags[i].Name < set.tags[j].Name || set.tags[i].Name == set.tags[j].Name && set.tags[i].ID < set.tags[j].ID
|
||||||
})
|
})
|
||||||
|
for i := range set.tags {
|
||||||
|
tag := &set.tags[i]
|
||||||
|
ref := "t" + strconv.Itoa(i+1)
|
||||||
|
set.tagIDs[ref] = tag.ID
|
||||||
|
set.tagRefs[tag.ID] = ref
|
||||||
|
tag.ID = ref
|
||||||
|
}
|
||||||
usual := map[string]string{}
|
usual := map[string]string{}
|
||||||
counts := map[string]map[string]int{}
|
counts := map[string]map[string]int{}
|
||||||
for _, tx := range data.Transactions {
|
for _, tx := range data.Transactions {
|
||||||
@@ -263,13 +279,19 @@ func retrieve(_ string, kind string, data domain.Dataset, clean, merchantClean f
|
|||||||
}
|
}
|
||||||
set.merchants = append(set.merchants, merchantPrompt{
|
set.merchants = append(set.merchants, merchantPrompt{
|
||||||
ID: merchant.ID, Name: name, Aliases: aliases,
|
ID: merchant.ID, Name: name, Aliases: aliases,
|
||||||
UsualCategory: usualCategory,
|
UsualCategory: set.categoryRefs[usualCategory],
|
||||||
})
|
})
|
||||||
set.merchantIDs[merchant.ID] = merchant.ID
|
|
||||||
}
|
}
|
||||||
sort.Slice(set.merchants, func(i, j int) bool {
|
sort.Slice(set.merchants, func(i, j int) bool {
|
||||||
return set.merchants[i].Name < set.merchants[j].Name || set.merchants[i].Name == set.merchants[j].Name && set.merchants[i].ID < set.merchants[j].ID
|
return set.merchants[i].Name < set.merchants[j].Name || set.merchants[i].Name == set.merchants[j].Name && set.merchants[i].ID < set.merchants[j].ID
|
||||||
})
|
})
|
||||||
|
for i := range set.merchants {
|
||||||
|
merchant := &set.merchants[i]
|
||||||
|
ref := "m" + strconv.Itoa(i+1)
|
||||||
|
set.merchantIDs[ref] = merchant.ID
|
||||||
|
set.merchantRefs[merchant.ID] = ref
|
||||||
|
merchant.ID = ref
|
||||||
|
}
|
||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,16 +336,12 @@ func candidateIDs(values []categoryPrompt) []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func answerSchema(d domain.Dataset, kind string) map[string]any {
|
// history selects precedent whose category is offered in this request: the
|
||||||
return retrieve("", kind, d, nil, nil).schema()
|
// nearest rows by word overlap, filled out with the most recent. The user's
|
||||||
}
|
// own decisions — manual edits and locally applied merchant rules — outrank
|
||||||
|
// rows the model classified itself. References use the same mapping as the
|
||||||
// history selects precedent for the prompt: the nearest rows by word overlap,
|
// candidate lists and response schema.
|
||||||
// filled out with the most recent. The user's own decisions — manual edits
|
func (c candidateSet) history(f domain.Facts, d domain.Dataset, clean func(string) string, limit int) []promptHistory {
|
||||||
// and locally applied merchant rules — outrank rows the model classified
|
|
||||||
// itself, so one correction beats any number of uncorrected AI answers for
|
|
||||||
// the same payee.
|
|
||||||
func history(f domain.Facts, d domain.Dataset, clean func(string) string, limit int) []promptHistory {
|
|
||||||
type row struct {
|
type row struct {
|
||||||
tx domain.Transaction
|
tx domain.Transaction
|
||||||
score int
|
score int
|
||||||
@@ -332,7 +350,7 @@ func history(f domain.Facts, d domain.Dataset, clean func(string) string, limit
|
|||||||
rows := []row{}
|
rows := []row{}
|
||||||
for _, tx := range d.Transactions {
|
for _, tx := range d.Transactions {
|
||||||
e := tx.Enrichment
|
e := tx.Enrichment
|
||||||
if tx.Facts.ID == f.ID || e.Kind == "transfer" || e.CategoryID == "" || e.CategoryID == domain.ExpenseFallback || e.CategoryID == domain.IncomeFallback {
|
if tx.Facts.ID == f.ID || e.Kind == "transfer" || c.categoryRefs[e.CategoryID] == "" || e.CategoryID == domain.ExpenseFallback || e.CategoryID == domain.IncomeFallback {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
source := tx.Enrichment.Classification.Source
|
source := tx.Enrichment.Classification.Source
|
||||||
@@ -382,9 +400,11 @@ func history(f domain.Facts, d domain.Dataset, clean func(string) string, limit
|
|||||||
}
|
}
|
||||||
out := make([]promptHistory, 0, len(rows))
|
out := make([]promptHistory, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
tags := row.tx.Enrichment.TagIDs
|
tags := make([]string, 0, len(row.tx.Enrichment.TagIDs))
|
||||||
if tags == nil {
|
for _, id := range row.tx.Enrichment.TagIDs {
|
||||||
tags = []string{}
|
if ref := c.tagRefs[id]; ref != "" {
|
||||||
|
tags = append(tags, ref)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
source := "ai"
|
source := "ai"
|
||||||
if row.user {
|
if row.user {
|
||||||
@@ -393,8 +413,8 @@ func history(f domain.Facts, d domain.Dataset, clean func(string) string, limit
|
|||||||
out = append(out, promptHistory{
|
out = append(out, promptHistory{
|
||||||
Date: row.tx.Facts.BookingDate, Amount: string(row.tx.Facts.Amount),
|
Date: row.tx.Facts.BookingDate, Amount: string(row.tx.Facts.Amount),
|
||||||
Description: clean(row.tx.Facts.RawDescription), Counterparty: clean(row.tx.Facts.Counterparty),
|
Description: clean(row.tx.Facts.RawDescription), Counterparty: clean(row.tx.Facts.Counterparty),
|
||||||
CategoryID: row.tx.Enrichment.CategoryID, MerchantID: row.tx.Enrichment.MerchantID,
|
CategoryID: c.categoryRefs[row.tx.Enrichment.CategoryID], MerchantID: c.merchantRefs[row.tx.Enrichment.MerchantID],
|
||||||
TagIDs: append([]string{}, tags...),
|
TagIDs: tags,
|
||||||
Source: source,
|
Source: source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.D
|
|||||||
userPayload.Transaction.Counterparty = clean(facts.Counterparty)
|
userPayload.Transaction.Counterparty = clean(facts.Counterparty)
|
||||||
userPayload.Transaction.Account.Institution = clean(institution)
|
userPayload.Transaction.Account.Institution = clean(institution)
|
||||||
userPayload.Transaction.Account.Currency = facts.Currency
|
userPayload.Transaction.Account.Currency = facts.Currency
|
||||||
userPayload.History = history(facts, data, clean, 40)
|
userPayload.History = candidates.history(facts, data, clean, 40)
|
||||||
userPayload.Categories = candidates.categories
|
userPayload.Categories = candidates.categories
|
||||||
userPayload.Tags = candidates.tags
|
userPayload.Tags = candidates.tags
|
||||||
userPayload.Merchants = candidates.merchants
|
userPayload.Merchants = candidates.merchants
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func fixture() (domain.Facts, domain.Dataset) {
|
|||||||
return f, d
|
return f, d
|
||||||
}
|
}
|
||||||
|
|
||||||
const validAnswer = `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"medium"}`
|
const validAnswer = `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"medium"}`
|
||||||
|
|
||||||
func reply(w http.ResponseWriter, content string) {
|
func reply(w http.ResponseWriter, content string) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -44,6 +44,39 @@ func mockClient(t *testing.T, handler http.HandlerFunc) *Client {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type classificationPrompt struct {
|
||||||
|
Categories []categoryPrompt `json:"categories"`
|
||||||
|
Tags []tagPrompt `json:"tags"`
|
||||||
|
Merchants []merchantPrompt `json:"merchants"`
|
||||||
|
History []promptHistory `json:"history"`
|
||||||
|
Transactions []struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Counterparty string `json:"counterparty"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
} `json:"transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeClassificationPrompt(t *testing.T, r *http.Request) classificationPrompt {
|
||||||
|
t.Helper()
|
||||||
|
var req struct {
|
||||||
|
Messages []struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(req.Messages) != 2 {
|
||||||
|
t.Fatalf("expected system and user messages, got %d", len(req.Messages))
|
||||||
|
}
|
||||||
|
var prompt classificationPrompt
|
||||||
|
if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplicitDefaultsAreOptInAndBypassAI(t *testing.T) {
|
func TestExplicitDefaultsAreOptInAndBypassAI(t *testing.T) {
|
||||||
f, d := fixture()
|
f, d := fixture()
|
||||||
d.Merchants[0].UseDefaults = true
|
d.Merchants[0].UseDefaults = true
|
||||||
@@ -72,7 +105,22 @@ func TestForceAIOverridesRuleWithoutChangingKind(t *testing.T) {
|
|||||||
f, d := fixture()
|
f, d := fixture()
|
||||||
d.Merchants[0].UseDefaults = true
|
d.Merchants[0].UseDefaults = true
|
||||||
calls := 0
|
calls := 0
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { calls++; reply(w, validAnswer) })
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
|
// Food is an expense-only choice; do not reuse c1 after the request
|
||||||
|
// switches to income, where that reference names a different category.
|
||||||
|
categoryID := "c999"
|
||||||
|
for _, category := range prompt.Categories {
|
||||||
|
if category.Path == normalize(domain.CategoryPath(d, "cat_food")) {
|
||||||
|
categoryID = category.ID
|
||||||
|
}
|
||||||
|
if calls == 2 && category.Kind != "income" {
|
||||||
|
t.Errorf("income request offered an expense category: %+v", category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply(w, fmt.Sprintf(`{"merchant_id":null,"new_merchant":null,"category_id":%q,"tag_ids":[],"confidence":"medium"}`, categoryID))
|
||||||
|
})
|
||||||
p, err := c.Classify(context.Background(), f, d, true)
|
p, err := c.Classify(context.Background(), f, d, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -82,7 +130,7 @@ func TestForceAIOverridesRuleWithoutChangingKind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
f.Amount = "918.27"
|
f.Amount = "918.27"
|
||||||
p, err = c.Classify(context.Background(), f, d, true)
|
p, err = c.Classify(context.Background(), f, d, true)
|
||||||
if err == nil || p.Enrichment.Kind != "income" || p.Enrichment.CategoryID != domain.IncomeFallback {
|
if err == nil || calls != 2 || p.Enrichment.Kind != "income" || p.Enrichment.CategoryID != domain.IncomeFallback {
|
||||||
t.Fatalf("income sign: %+v %v", p, err)
|
t.Fatalf("income sign: %+v %v", p, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,21 +165,24 @@ func TestTransferNeverCallsAIOrAliases(t *testing.T) {
|
|||||||
|
|
||||||
func TestInvalidModelOutputsFailClosed(t *testing.T) {
|
func TestInvalidModelOutputsFailClosed(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"unknown key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":0.9}`,
|
"unknown key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high","unexpected":true}`,
|
||||||
"change kind": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"kind":"transfer"}`,
|
"change kind": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high","kind":"transfer"}`,
|
||||||
"missing field": `{"merchant_id":null,"category_id":"c1","tag_ids":[]}`,
|
"missing field": `{"merchant_id":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
|
||||||
"duplicate key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","category_id":"c2","tag_ids":[]}`,
|
"duplicate key": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","category_id":"c2","tag_ids":[],"confidence":"high"}`,
|
||||||
"case folded key": `{"Merchant_ID":null,"new_merchant":null,"category_id":"c1","tag_ids":[]}`,
|
"case folded key": `{"Merchant_ID":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
|
||||||
"unknown category": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_invented","tag_ids":[]}`,
|
"unknown category": `{"merchant_id":null,"new_merchant":null,"category_id":"c999","tag_ids":[],"confidence":"high"}`,
|
||||||
"real ID not offered": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[]}`,
|
"canonical category": `{"merchant_id":null,"new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`,
|
||||||
"unknown tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t999"]}`,
|
"unknown tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t999"],"confidence":"high"}`,
|
||||||
"duplicate tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t1","t1"]}`,
|
"canonical tag": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["tag_daily"],"confidence":"high"}`,
|
||||||
"null tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":null}`,
|
"duplicate tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":["t1","t1"],"confidence":"high"}`,
|
||||||
"null tag member": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[null]}`,
|
"null tags": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":null,"confidence":"high"}`,
|
||||||
"unknown merchant": `{"merchant_id":"m999","new_merchant":null,"category_id":"c1","tag_ids":[]}`,
|
"null tag member": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[null],"confidence":"high"}`,
|
||||||
"both merchant modes": `{"merchant_id":"m1","new_merchant":"Coffee","category_id":"c1","tag_ids":[]}`,
|
"unknown merchant": `{"merchant_id":"m999","new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
|
||||||
"blank proposal": `{"merchant_id":null,"new_merchant":" ","category_id":"c1","tag_ids":[]}`,
|
"canonical merchant": `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
|
||||||
"wrong scalar": `{"merchant_id":23,"new_merchant":null,"category_id":"c1","tag_ids":[]}`,
|
"both merchant modes": `{"merchant_id":"m1","new_merchant":"Coffee","category_id":"c1","tag_ids":[],"confidence":"high"}`,
|
||||||
|
"blank proposal": `{"merchant_id":null,"new_merchant":" ","category_id":"c1","tag_ids":[],"confidence":"high"}`,
|
||||||
|
"wrong scalar": `{"merchant_id":23,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`,
|
||||||
|
"numeric confidence": `{"merchant_id":null,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":0.9}`,
|
||||||
"trailing JSON": validAnswer + ` {}`,
|
"trailing JSON": validAnswer + ` {}`,
|
||||||
"markdown": "```json\n" + validAnswer + "\n```",
|
"markdown": "```json\n" + validAnswer + "\n```",
|
||||||
"array": "[" + validAnswer + "]",
|
"array": "[" + validAnswer + "]",
|
||||||
@@ -157,9 +208,9 @@ func TestMerchantSelectionAndLocalProposal(t *testing.T) {
|
|||||||
name, content, merchant string
|
name, content, merchant string
|
||||||
new bool
|
new bool
|
||||||
}{
|
}{
|
||||||
{"existing", `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "mer_coffee", false},
|
{"existing", `{"merchant_id":"m1","new_merchant":null,"category_id":"c1","tag_ids":["t1"],"confidence":"high"}`, "mer_coffee", false},
|
||||||
{"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "mer_coffee", false},
|
{"duplicate alias", `{"merchant_id":null,"new_merchant":"COFFEE-house","category_id":"c1","tag_ids":["t1"],"confidence":"high"}`, "mer_coffee", false},
|
||||||
{"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"high"}`, "", true},
|
{"new", `{"merchant_id":null,"new_merchant":"Bakery Lane","category_id":"c1","tag_ids":["t1"],"confidence":"high"}`, "", true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -216,18 +267,14 @@ func TestIdentifierOnlyPromptRedactionAndRouting(t *testing.T) {
|
|||||||
if len(messages) != 2 {
|
if len(messages) != 2 {
|
||||||
t.Fatal("unexpected messages")
|
t.Fatal("unexpected messages")
|
||||||
}
|
}
|
||||||
var prompt struct {
|
wire, err := json.Marshal(captured)
|
||||||
Transaction map[string]any `json:"transaction"`
|
if err != nil {
|
||||||
History []any `json:"history"`
|
|
||||||
Categories []any `json:"categories"`
|
|
||||||
Tags []any `json:"tags"`
|
|
||||||
Merchants []any `json:"merchants"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(messages[1].Content), &prompt); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(prompt.Transaction) == 0 || len(prompt.Categories) == 0 || len(prompt.Merchants) == 0 {
|
for _, canonicalID := range []string{"cat_food", "cat_expenses", "cat_income", "mer_coffee", "tag_daily"} {
|
||||||
t.Fatal("complete structured prompt missing")
|
if strings.Contains(string(wire), canonicalID) {
|
||||||
|
t.Errorf("request or response schema exposed canonical ID %q", canonicalID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(messages[1].Content)
|
lower := strings.ToLower(messages[1].Content)
|
||||||
for _, secret := range []string{"private_external", "private_fingerprint", "tx_private", "account_private", "ext_local_secret", "private_source", "personal checking", "550e8400", "cobadeff", "secretpayment", "example.com", "alice privateperson", "de89370400440532013000", "de44500105175407324931"} {
|
for _, secret := range []string{"private_external", "private_fingerprint", "tx_private", "account_private", "ext_local_secret", "private_source", "personal checking", "550e8400", "cobadeff", "secretpayment", "example.com", "alice privateperson", "de89370400440532013000", "de44500105175407324931"} {
|
||||||
@@ -300,7 +347,7 @@ func TestUnsafeMerchantProposalDroppedWithoutLosingClassification(t *testing.T)
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
f, d := fixture()
|
f, d := fixture()
|
||||||
f.Counterparty = "Alice Privateperson"
|
f.Counterparty = "Alice Privateperson"
|
||||||
answer, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": name, "category_id": "cat_food", "tag_ids": []string{}, "confidence": "high"})
|
answer, _ := json.Marshal(map[string]any{"merchant_id": nil, "new_merchant": name, "category_id": "c1", "tag_ids": []string{}, "confidence": "high"})
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, string(answer)) })
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) { reply(w, string(answer)) })
|
||||||
c.PrivateNames = []string{"Alice Privateperson"}
|
c.PrivateNames = []string{"Alice Privateperson"}
|
||||||
p, err := c.Classify(context.Background(), f, d, true)
|
p, err := c.Classify(context.Background(), f, d, true)
|
||||||
@@ -410,33 +457,67 @@ func TestCompleteRegistryPayloadAndGlobalDuplicateDetection(t *testing.T) {
|
|||||||
d.Tags = append(d.Tags, domain.Tag{ID: fmt.Sprintf("tag_%02d", i), Name: fmt.Sprintf("Tag %02d", i)})
|
d.Tags = append(d.Tags, domain.Tag{ID: fmt.Sprintf("tag_%02d", i), Name: fmt.Sprintf("Tag %02d", i)})
|
||||||
d.Categories = append(d.Categories, domain.Category{ID: fmt.Sprintf("cat_%02d", i), Name: fmt.Sprintf("Category %02d", i), Kind: "expense", ParentID: "cat_expenses"})
|
d.Categories = append(d.Categories, domain.Category{ID: fmt.Sprintf("cat_%02d", i), Name: fmt.Sprintf("Category %02d", i), Kind: "expense", ParentID: "cat_expenses"})
|
||||||
}
|
}
|
||||||
d.Merchants[34].Name = "Distant Bakery"
|
d.Merchants[34].Name = "Z Distant Bakery"
|
||||||
set := retrieve(f.RawDescription, "expense", d, redactor(d, f, nil), redactor(d, f, nil))
|
|
||||||
if len(set.merchantIDs) != 35 || len(set.tags) != 36 {
|
|
||||||
t.Fatalf("complete registry omitted entries: merchants=%d tags=%d", len(set.merchantIDs), len(set.tags))
|
|
||||||
}
|
|
||||||
if set.merchantIDs["mer_34"] != "mer_34" ||
|
|
||||||
set.tagIDs["tag_34"] != "tag_34" ||
|
|
||||||
set.categoryIDs["cat_34"] != "cat_34" {
|
|
||||||
t.Fatal("registry omitted real ids")
|
|
||||||
}
|
|
||||||
before := domain.Clone(d)
|
before := domain.Clone(d)
|
||||||
|
for _, mode := range []string{"existing", "duplicate name"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
content, _ := json.Marshal(map[string]any{
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
"merchant_id": "mer_34",
|
if len(prompt.Merchants) != 35 || len(prompt.Tags) != 36 || len(prompt.Categories) != 37 {
|
||||||
"new_merchant": nil,
|
t.Fatalf("complete candidates missing: merchants=%d tags=%d categories=%d", len(prompt.Merchants), len(prompt.Tags), len(prompt.Categories))
|
||||||
"category_id": "cat_34",
|
}
|
||||||
"tag_ids": []string{"tag_34"},
|
merchants, categories, tags := map[string]string{}, map[string]string{}, map[string]string{}
|
||||||
|
for _, merchant := range prompt.Merchants {
|
||||||
|
merchants[merchant.Name] = merchant.ID
|
||||||
|
}
|
||||||
|
for _, category := range prompt.Categories {
|
||||||
|
if category.Kind != "expense" {
|
||||||
|
t.Errorf("ineligible category candidate: %+v", category)
|
||||||
|
}
|
||||||
|
categories[category.Path] = category.ID
|
||||||
|
}
|
||||||
|
for _, tag := range prompt.Tags {
|
||||||
|
tags[tag.Name] = tag.ID
|
||||||
|
}
|
||||||
|
for _, merchant := range d.Merchants {
|
||||||
|
if merchants[normalize(merchant.Name)] == "" {
|
||||||
|
t.Errorf("merchant omitted: %s", merchant.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, category := range d.Categories {
|
||||||
|
if category.Kind == "expense" && category.ID != "cat_expenses" && categories[normalize(domain.CategoryPath(d, category.ID))] == "" {
|
||||||
|
t.Errorf("eligible category omitted: %s", category.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range d.Tags {
|
||||||
|
if tags[normalize(tag.Name)] == "" {
|
||||||
|
t.Errorf("tag omitted: %s", tag.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var merchantID, newMerchant any = merchants["z distant bakery"], nil
|
||||||
|
if mode == "duplicate name" {
|
||||||
|
merchantID, newMerchant = nil, "Z Distant Bakery"
|
||||||
|
}
|
||||||
|
content, err := json.Marshal(map[string]any{
|
||||||
|
"merchant_id": merchantID,
|
||||||
|
"new_merchant": newMerchant,
|
||||||
|
"category_id": categories[normalize(domain.CategoryPath(d, "cat_34"))],
|
||||||
|
"tag_ids": []string{tags["tag 34"]},
|
||||||
"confidence": "high",
|
"confidence": "high",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
reply(w, string(content))
|
reply(w, string(content))
|
||||||
})
|
})
|
||||||
p, err := c.Classify(context.Background(), f, d, true)
|
p, err := c.Classify(context.Background(), f, d, true)
|
||||||
if err != nil || p.Enrichment.MerchantID != "mer_34" || p.Enrichment.CategoryID != "cat_34" || !reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_34"}) {
|
if err != nil || p.NewMerchant != nil || p.Enrichment.MerchantID != "mer_34" || p.Enrichment.CategoryID != "cat_34" || !reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_34"}) {
|
||||||
t.Fatalf("complete registry selection failed: %+v %v", p, err)
|
t.Fatalf("complete registry selection failed: %+v %v", p, err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(before, d) {
|
if !reflect.DeepEqual(before, d) {
|
||||||
t.Fatal("retrieval mutated registry order")
|
t.Fatal("classification mutated the dataset")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +561,7 @@ func TestConfiguredPrivateNamesAndIdentifiersRedactWithoutRemovingPayee(t *testi
|
|||||||
func TestLowConfidenceKeepsProposalAndRecordsConfidence(t *testing.T) {
|
func TestLowConfidenceKeepsProposalAndRecordsConfidence(t *testing.T) {
|
||||||
f, d := fixture()
|
f, d := fixture()
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
reply(w, `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":["tag_daily"],"confidence":"low"}`)
|
reply(w, `{"merchant_id":"m1","new_merchant":null,"category_id":"c1","tag_ids":["t1"],"confidence":"low"}`)
|
||||||
})
|
})
|
||||||
p, err := c.Classify(context.Background(), f, d, true)
|
p, err := c.Classify(context.Background(), f, d, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -547,7 +628,7 @@ func TestPayeeAndPublicMerchantAreSentToAI(t *testing.T) {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Counterparty string `json:"counterparty"`
|
Counterparty string `json:"counterparty"`
|
||||||
} `json:"transaction"`
|
} `json:"transaction"`
|
||||||
Merchants []candidate `json:"merchants"`
|
Merchants []merchantPrompt `json:"merchants"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil {
|
if err := json.Unmarshal([]byte(req.Messages[1].Content), &prompt); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -558,7 +639,7 @@ func TestPayeeAndPublicMerchantAreSentToAI(t *testing.T) {
|
|||||||
if len(prompt.Merchants) != 26 || prompt.Merchants[0].Name != "coffee house" {
|
if len(prompt.Merchants) != 26 || prompt.Merchants[0].Name != "coffee house" {
|
||||||
t.Fatalf("complete merchant registry missing: %d", len(prompt.Merchants))
|
t.Fatalf("complete merchant registry missing: %d", len(prompt.Merchants))
|
||||||
}
|
}
|
||||||
reply(w, `{"merchant_id":"mer_coffee","new_merchant":null,"category_id":"cat_food","tag_ids":[],"confidence":"high"}`)
|
reply(w, fmt.Sprintf(`{"merchant_id":%q,"new_merchant":null,"category_id":"c1","tag_ids":[],"confidence":"high"}`, prompt.Merchants[0].ID))
|
||||||
})
|
})
|
||||||
p, err := c.Classify(context.Background(), f, d, true)
|
p, err := c.Classify(context.Background(), f, d, true)
|
||||||
if err != nil || p.Enrichment.MerchantID != "mer_coffee" {
|
if err != nil || p.Enrichment.MerchantID != "mer_coffee" {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package classification
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -60,6 +62,17 @@ func ledgerFixture() (domain.Dataset, domain.Facts) {
|
|||||||
return d, facts
|
return d, facts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func categoryRefForPath(t *testing.T, categories []categoryPrompt, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, category := range categories {
|
||||||
|
if category.Path == path {
|
||||||
|
return category.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("category path %q missing from prompt: %+v", path, categories)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// strictKeywords is what every targeted provider accepts in strict
|
// strictKeywords is what every targeted provider accepts in strict
|
||||||
// structured-output mode. uniqueItems is rejected outright by OpenAI-family
|
// structured-output mode. uniqueItems is rejected outright by OpenAI-family
|
||||||
// endpoints ("'uniqueItems' is not permitted"); minItems/maxItems make Gemini
|
// endpoints ("'uniqueItems' is not permitted"); minItems/maxItems make Gemini
|
||||||
@@ -115,7 +128,9 @@ func TestLedgerRowClassifiesThroughStrictSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
reply(w, `{"merchant_id":null,"new_merchant":"Finanzamt Bruehl","category_id":"`+taxes+`","tag_ids":[],"confidence":"high"}`)
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
|
category := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, taxes)))
|
||||||
|
reply(w, `{"merchant_id":null,"new_merchant":"Finanzamt Bruehl","category_id":"`+category+`","tag_ids":[],"confidence":"high"}`)
|
||||||
})
|
})
|
||||||
p, err := c.Classify(context.Background(), facts, d, true)
|
p, err := c.Classify(context.Background(), facts, d, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,18 +203,123 @@ func TestManualCorrectionsOutrankAIPrecedent(t *testing.T) {
|
|||||||
add("tx_corrected", "2026-08-01", events, "manual")
|
add("tx_corrected", "2026-08-01", events, "manual")
|
||||||
target := domain.Facts{ID: "tx_new", AccountID: "acct_kontist", BookingDate: "2026-08-30",
|
target := domain.Facts{ID: "tx_new", AccountID: "acct_kontist", BookingDate: "2026-08-30",
|
||||||
Amount: "-13.00", Currency: "EUR", Counterparty: "LVR Landesmuseum Bonn"}
|
Amount: "-13.00", Currency: "EUR", Counterparty: "LVR Landesmuseum Bonn"}
|
||||||
rows := history(target, d, func(s string) string { return normalize(s) }, 20)
|
set := retrieve("", "expense", d, nil, nil)
|
||||||
if len(rows) == 0 || rows[0].Source != "user" || rows[0].CategoryID != events {
|
rows := set.history(target, d, normalize, 20)
|
||||||
|
eventsRef := categoryRefForPath(t, set.categories, domain.CategoryPath(d, events))
|
||||||
|
if len(rows) == 0 {
|
||||||
|
t.Fatal("manual correction missing from precedent")
|
||||||
|
}
|
||||||
|
if rows[0].Source != "user" || rows[0].CategoryID != eventsRef {
|
||||||
t.Fatalf("manual correction did not lead precedent: %+v", rows[0])
|
t.Fatalf("manual correction did not lead precedent: %+v", rows[0])
|
||||||
}
|
}
|
||||||
// The correction keeps its slot even in a window the AI rows could fill.
|
}
|
||||||
users := 0
|
|
||||||
for _, row := range rows {
|
func TestHistoryReferencesResolveThroughCurrentRequestCandidates(t *testing.T) {
|
||||||
if row.Source == "user" {
|
facts, d := fixture()
|
||||||
users++
|
d.Categories = append(d.Categories, domain.Category{ID: "cat_salary", Name: "Salary", ParentID: "cat_income", Kind: "income"})
|
||||||
|
d.Merchants = append(d.Merchants, domain.Merchant{ID: "mer_payroll", Name: "Payroll", DefaultCategoryID: "cat_salary"})
|
||||||
|
manual := facts
|
||||||
|
manual.ID, manual.Fingerprint, manual.BookingDate = "tx_manual", "fp_manual", "2026-08-01"
|
||||||
|
d.Transactions = append(d.Transactions, domain.Transaction{Facts: manual, Enrichment: domain.Enrichment{
|
||||||
|
Kind: "expense", CategoryID: "cat_food", MerchantID: "mer_coffee", TagIDs: []string{"tag_daily"},
|
||||||
|
Classification: domain.Provenance{Source: "manual"},
|
||||||
|
}})
|
||||||
|
income := manual
|
||||||
|
income.ID, income.Fingerprint, income.BookingDate, income.Amount = "tx_income", "fp_income", "2026-08-31", "100.00"
|
||||||
|
d.Transactions = append(d.Transactions, domain.Transaction{Facts: income, Enrichment: domain.Enrichment{
|
||||||
|
Kind: "income", CategoryID: "cat_salary", MerchantID: "mer_payroll", TagIDs: []string{"tag_daily"},
|
||||||
|
Classification: domain.Provenance{Source: "manual"},
|
||||||
|
}})
|
||||||
|
categoryPattern := regexp.MustCompile(`^c[1-9][0-9]*$`)
|
||||||
|
merchantPattern := regexp.MustCompile(`^m[1-9][0-9]*$`)
|
||||||
|
tagPattern := regexp.MustCompile(`^t[1-9][0-9]*$`)
|
||||||
|
expectedCategories := 2
|
||||||
|
c := mockClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
prompt := decodeClassificationPrompt(t, r)
|
||||||
|
if len(prompt.Categories) != expectedCategories || len(prompt.Merchants) != len(d.Merchants) || len(prompt.Tags) != len(d.Tags) {
|
||||||
|
t.Errorf("request lost eligible registry candidates: categories=%d merchants=%d tags=%d",
|
||||||
|
len(prompt.Categories), len(prompt.Merchants), len(prompt.Tags))
|
||||||
|
}
|
||||||
|
categories := make(map[string]bool)
|
||||||
|
for _, candidate := range prompt.Categories {
|
||||||
|
if !categoryPattern.MatchString(candidate.ID) || candidate.Kind != "expense" || categories[candidate.ID] {
|
||||||
|
t.Errorf("invalid expense category reference: %+v", candidate)
|
||||||
|
}
|
||||||
|
categories[candidate.ID] = true
|
||||||
|
}
|
||||||
|
food := categoryRefForPath(t, prompt.Categories, normalize(domain.CategoryPath(d, "cat_food")))
|
||||||
|
merchants := make(map[string]bool)
|
||||||
|
coffee := ""
|
||||||
|
for _, candidate := range prompt.Merchants {
|
||||||
|
if !merchantPattern.MatchString(candidate.ID) || merchants[candidate.ID] {
|
||||||
|
t.Errorf("invalid merchant reference: %+v", candidate)
|
||||||
|
}
|
||||||
|
merchants[candidate.ID] = true
|
||||||
|
if candidate.UsualCategory != "" && !categories[candidate.UsualCategory] {
|
||||||
|
t.Errorf("merchant has dangling usual category: %+v", candidate)
|
||||||
|
}
|
||||||
|
if candidate.Name == "coffee house" {
|
||||||
|
coffee = candidate.ID
|
||||||
|
if candidate.UsualCategory != food {
|
||||||
|
t.Errorf("merchant usual category does not identify Food: %+v", candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if users == 0 {
|
}
|
||||||
t.Fatal("correction crowded out of the history window")
|
tags := make(map[string]bool)
|
||||||
|
daily := ""
|
||||||
|
for _, candidate := range prompt.Tags {
|
||||||
|
if !tagPattern.MatchString(candidate.ID) || tags[candidate.ID] {
|
||||||
|
t.Errorf("invalid tag reference: %+v", candidate)
|
||||||
|
}
|
||||||
|
tags[candidate.ID] = true
|
||||||
|
if candidate.Name == "daily" {
|
||||||
|
daily = candidate.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if coffee == "" || daily == "" {
|
||||||
|
t.Error("request lost Coffee House or Daily")
|
||||||
|
}
|
||||||
|
if len(prompt.History) != 1 {
|
||||||
|
t.Errorf("expected only applicable manual expense history, got %+v", prompt.History)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
history := prompt.History[0]
|
||||||
|
if history.Source != "user" || history.CategoryID != food || history.MerchantID != coffee ||
|
||||||
|
!reflect.DeepEqual(history.TagIDs, []string{daily}) {
|
||||||
|
t.Errorf("manual history references do not match offered records: %+v", history)
|
||||||
|
}
|
||||||
|
// Copying the correction must select the original registry records, not
|
||||||
|
// whatever records occupied these request-local references previously.
|
||||||
|
answer, err := json.Marshal(map[string]any{
|
||||||
|
"merchant_id": history.MerchantID, "new_merchant": nil,
|
||||||
|
"category_id": history.CategoryID, "tag_ids": history.TagIDs, "confidence": "high",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply(w, string(answer))
|
||||||
|
})
|
||||||
|
for _, name := range []string{"original registry", "shifted registry"} {
|
||||||
|
if name == "shifted registry" {
|
||||||
|
// New names sort before every selected record and change all three
|
||||||
|
// references without changing the canonical correction.
|
||||||
|
d.Categories = append(d.Categories, domain.Category{ID: "cat_early", Name: "Aardvark", ParentID: "cat_expenses", Kind: "expense"})
|
||||||
|
d.Merchants = append(d.Merchants, domain.Merchant{ID: "mer_early", Name: "Aardvark"})
|
||||||
|
d.Tags = append(d.Tags, domain.Tag{ID: "tag_early", Name: "Aardvark"})
|
||||||
|
expectedCategories++
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
p, err := c.Classify(context.Background(), facts, d, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.NewMerchant != nil || p.Enrichment.CategoryID != "cat_food" || p.Enrichment.MerchantID != "mer_coffee" ||
|
||||||
|
!reflect.DeepEqual(p.Enrichment.TagIDs, []string{"tag_daily"}) {
|
||||||
|
t.Fatalf("manual precedent resolved to wrong canonical records: %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func NewDataset() Dataset {
|
|||||||
return Dataset{Accounts: []Account{}, Categories: []Category{
|
return Dataset{Accounts: []Account{}, Categories: []Category{
|
||||||
{ID: "cat_expenses", Name: "Expenses", Kind: "expense"}, {ID: ExpenseFallback, Name: "Unclassified", ParentID: "cat_expenses", Kind: "expense"},
|
{ID: "cat_expenses", Name: "Expenses", Kind: "expense"}, {ID: ExpenseFallback, Name: "Unclassified", ParentID: "cat_expenses", Kind: "expense"},
|
||||||
{ID: "cat_income", Name: "Income", Kind: "income"}, {ID: IncomeFallback, Name: "Unclassified", ParentID: "cat_income", Kind: "income"},
|
{ID: "cat_income", Name: "Income", Kind: "income"}, {ID: IncomeFallback, Name: "Unclassified", ParentID: "cat_income", Kind: "income"},
|
||||||
}, Tags: []Tag{}, Merchants: []Merchant{}, Instruments: []Instrument{}, Transactions: []Transaction{}}
|
}, Tags: []Tag{}, Merchants: []Merchant{}, Instruments: []Instrument{}, Assets: []Asset{}, Transactions: []Transaction{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstrumentID derives a stable registry ID from an ISIN so re-importing the
|
// InstrumentID derives a stable registry ID from an ISIN so re-importing the
|
||||||
@@ -181,7 +181,7 @@ func InstrumentID(isin string) string {
|
|||||||
return "ins_" + hex.EncodeToString(sum[:16])
|
return "ins_" + hex.EncodeToString(sum[:16])
|
||||||
}
|
}
|
||||||
func Clone(d Dataset) Dataset {
|
func Clone(d Dataset) Dataset {
|
||||||
c := Dataset{Accounts: append([]Account{}, d.Accounts...), Categories: append([]Category{}, d.Categories...), Tags: append([]Tag{}, d.Tags...), Merchants: append([]Merchant{}, d.Merchants...), Instruments: append([]Instrument{}, d.Instruments...), Transactions: append([]Transaction{}, d.Transactions...)}
|
c := Dataset{Accounts: append([]Account{}, d.Accounts...), Categories: append([]Category{}, d.Categories...), Tags: append([]Tag{}, d.Tags...), Merchants: append([]Merchant{}, d.Merchants...), Instruments: append([]Instrument{}, d.Instruments...), Assets: append([]Asset{}, d.Assets...), Transactions: append([]Transaction{}, d.Transactions...)}
|
||||||
for i := range c.Merchants {
|
for i := range c.Merchants {
|
||||||
c.Merchants[i].Aliases = append([]string{}, d.Merchants[i].Aliases...)
|
c.Merchants[i].Aliases = append([]string{}, d.Merchants[i].Aliases...)
|
||||||
c.Merchants[i].DefaultTagIDs = append([]string{}, d.Merchants[i].DefaultTagIDs...)
|
c.Merchants[i].DefaultTagIDs = append([]string{}, d.Merchants[i].DefaultTagIDs...)
|
||||||
@@ -398,6 +398,22 @@ func Validate(d Dataset) error {
|
|||||||
isins[v.ISIN] = v.ID
|
isins[v.ISIN] = v.ID
|
||||||
instruments[v.ID] = v
|
instruments[v.ID] = v
|
||||||
}
|
}
|
||||||
|
for _, v := range d.Assets {
|
||||||
|
if err := register(v.ID, "asset"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !nonblank(v.Name) || !currencyPattern.MatchString(v.Currency) || !validText(v.Kind) {
|
||||||
|
return fmt.Errorf("asset %q: valid UTF-8 name and three-letter uppercase currency required", v.ID)
|
||||||
|
}
|
||||||
|
// A hand-stated value without its day cannot be judged stale, so the
|
||||||
|
// two are recorded together, always.
|
||||||
|
if _, err := v.Value.Minor(); err != nil {
|
||||||
|
return fmt.Errorf("asset %q: %w", v.ID, err)
|
||||||
|
}
|
||||||
|
if !validDate(v.ValuedAt) {
|
||||||
|
return fmt.Errorf("asset %q: invalid valuation date %q", v.ID, v.ValuedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, t := range d.Transactions {
|
for _, t := range d.Transactions {
|
||||||
f := t.Facts
|
f := t.Facts
|
||||||
if err := register(f.ID, "transaction"); err != nil {
|
if err := register(f.ID, "transaction"); err != nil {
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ type Instrument struct {
|
|||||||
QuotedAt string `json:"quoted_at,omitempty"`
|
QuotedAt string `json:"quoted_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asset is a possession valued by hand: a house, a car, anything without a
|
||||||
|
// market feed. Value is what the owner states it is worth and ValuedAt the day
|
||||||
|
// that estimate was made, so a stale figure is visible rather than silently
|
||||||
|
// trusted. A negative value records a liability such as a mortgage.
|
||||||
|
type Asset struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Kind is free display text grouping the asset: "Real estate", "Vehicle".
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Value Money `json:"value"`
|
||||||
|
ValuedAt string `json:"valued_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Facts struct {
|
type Facts struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
@@ -178,6 +192,7 @@ type Dataset struct {
|
|||||||
Tags []Tag `json:"tags"`
|
Tags []Tag `json:"tags"`
|
||||||
Merchants []Merchant `json:"merchants"`
|
Merchants []Merchant `json:"merchants"`
|
||||||
Instruments []Instrument `json:"instruments"`
|
Instruments []Instrument `json:"instruments"`
|
||||||
|
Assets []Asset `json:"assets"`
|
||||||
Transactions []Transaction `json:"transactions"`
|
Transactions []Transaction `json:"transactions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
// registryFiles are the non-monthly journal files, in the order they are read
|
// registryFiles are the non-monthly journal files, in the order they are read
|
||||||
// and written. A block's file is its kind pluralized, so this list and the
|
// and written. A block's file is its kind pluralized, so this list and the
|
||||||
// kinds accepted by parseDocument must stay in step.
|
// kinds accepted by parseDocument must stay in step.
|
||||||
var registryFiles = []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance", "instruments.finance"}
|
var registryFiles = []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance", "instruments.finance", "assets.finance"}
|
||||||
|
|
||||||
type fieldSpan struct{ start, end int }
|
type fieldSpan struct{ start, end int }
|
||||||
type block struct {
|
type block struct {
|
||||||
@@ -140,7 +140,7 @@ func parseDocument(path string, raw []byte) (*document, error) {
|
|||||||
}
|
}
|
||||||
header := strings.Fields(trimmed)
|
header := strings.Fields(trimmed)
|
||||||
if len(header) != 2 || header[1] != "{" {
|
if len(header) != 2 || header[1] != "{" {
|
||||||
return fail(i+1, "expected 'account|category|tag|merchant|instrument|transaction {'")
|
return fail(i+1, "expected 'account|category|tag|merchant|instrument|asset|transaction {'")
|
||||||
}
|
}
|
||||||
kind := header[0]
|
kind := header[0]
|
||||||
var value any
|
var value any
|
||||||
@@ -155,6 +155,8 @@ func parseDocument(path string, raw []byte) (*document, error) {
|
|||||||
value = &domain.Merchant{}
|
value = &domain.Merchant{}
|
||||||
case "instrument":
|
case "instrument":
|
||||||
value = &domain.Instrument{}
|
value = &domain.Instrument{}
|
||||||
|
case "asset":
|
||||||
|
value = &domain.Asset{}
|
||||||
case "transaction":
|
case "transaction":
|
||||||
value = &domain.Transaction{}
|
value = &domain.Transaction{}
|
||||||
default:
|
default:
|
||||||
@@ -237,6 +239,9 @@ func parseDocument(path string, raw []byte) (*document, error) {
|
|||||||
case *domain.Instrument:
|
case *domain.Instrument:
|
||||||
b.id = v.ID
|
b.id = v.ID
|
||||||
b.value = *v
|
b.value = *v
|
||||||
|
case *domain.Asset:
|
||||||
|
b.id = v.ID
|
||||||
|
b.value = *v
|
||||||
case *domain.Merchant:
|
case *domain.Merchant:
|
||||||
if v.Aliases == nil {
|
if v.Aliases == nil {
|
||||||
v.Aliases = []string{}
|
v.Aliases = []string{}
|
||||||
@@ -342,6 +347,9 @@ func datasetFiles(d domain.Dataset) map[string]map[string]piece {
|
|||||||
for _, v := range d.Instruments {
|
for _, v := range d.Instruments {
|
||||||
add("instruments.finance", "instrument", v.ID, v)
|
add("instruments.finance", "instrument", v.ID, v)
|
||||||
}
|
}
|
||||||
|
for _, v := range d.Assets {
|
||||||
|
add("assets.finance", "asset", v.ID, v)
|
||||||
|
}
|
||||||
for _, v := range d.Transactions {
|
for _, v := range d.Transactions {
|
||||||
month := v.Facts.BookingDate[:7]
|
month := v.Facts.BookingDate[:7]
|
||||||
add("journal/"+month[:4]+"/"+month+".finance", "transaction", v.Facts.ID, v)
|
add("journal/"+month[:4]+"/"+month+".finance", "transaction", v.Facts.ID, v)
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ func (s *Store) snapshot() (*snapshot, error) {
|
|||||||
return snap, nil
|
return snap, nil
|
||||||
}
|
}
|
||||||
func decodeSnapshot(raw map[string][]byte) (*snapshot, error) {
|
func decodeSnapshot(raw map[string][]byte) (*snapshot, error) {
|
||||||
snap := &snapshot{raw: raw, docs: map[string]*document{}, revision: revision(raw), data: domain.Dataset{Accounts: []domain.Account{}, Categories: []domain.Category{}, Tags: []domain.Tag{}, Merchants: []domain.Merchant{}, Instruments: []domain.Instrument{}, Transactions: []domain.Transaction{}}}
|
snap := &snapshot{raw: raw, docs: map[string]*document{}, revision: revision(raw), data: domain.Dataset{Accounts: []domain.Account{}, Categories: []domain.Category{}, Tags: []domain.Tag{}, Merchants: []domain.Merchant{}, Instruments: []domain.Instrument{}, Assets: []domain.Asset{}, Transactions: []domain.Transaction{}}}
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
snap.data = domain.NewDataset()
|
snap.data = domain.NewDataset()
|
||||||
return snap, nil
|
return snap, nil
|
||||||
@@ -483,6 +483,8 @@ func decodeSnapshot(raw map[string][]byte) (*snapshot, error) {
|
|||||||
snap.data.Merchants = append(snap.data.Merchants, v)
|
snap.data.Merchants = append(snap.data.Merchants, v)
|
||||||
case domain.Instrument:
|
case domain.Instrument:
|
||||||
snap.data.Instruments = append(snap.data.Instruments, v)
|
snap.data.Instruments = append(snap.data.Instruments, v)
|
||||||
|
case domain.Asset:
|
||||||
|
snap.data.Assets = append(snap.data.Assets, v)
|
||||||
case domain.Transaction:
|
case domain.Transaction:
|
||||||
snap.data.Transactions = append(snap.data.Transactions, v)
|
snap.data.Transactions = append(snap.data.Transactions, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,6 +514,30 @@ func TestNullListsPreserveUntouchedExternalBlockBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An asset is a registry entity like any other: committed to its own file and
|
||||||
|
// identical after a fresh load, or the wealth it backs vanishes on restart.
|
||||||
|
func TestAssetsSurviveCommitAndReload(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
d, r := loadTestStore(t, s)
|
||||||
|
d.Assets = []domain.Asset{{ID: "asset_house", Name: "House", Kind: "Real estate", Currency: "EUR", Value: "250000.00", ValuedAt: "2026-09-01"}}
|
||||||
|
commitTestStore(t, s, r, d)
|
||||||
|
if err := s.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fresh, err := Open(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer fresh.Close()
|
||||||
|
loaded, _ := loadTestStore(t, fresh)
|
||||||
|
if !reflect.DeepEqual(loaded.Assets, d.Assets) {
|
||||||
|
t.Errorf("assets after reload %+v, want %+v", loaded.Assets, d.Assets)
|
||||||
|
}
|
||||||
|
if raw := readTestFile(t, filepath.Join(s.dir, "assets.finance")); !bytes.Contains(raw, []byte(`asset {`)) {
|
||||||
|
t.Errorf("assets.finance holds no asset block: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOversizedCommitCannotPublishUnreadableRecoveryIntent(t *testing.T) {
|
func TestOversizedCommitCannotPublishUnreadableRecoveryIntent(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
original, r := loadTestStore(t, s)
|
original, r := loadTestStore(t, s)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
|
|||||||
s.mux.HandleFunc("POST /api/tags", s.tag)
|
s.mux.HandleFunc("POST /api/tags", s.tag)
|
||||||
s.mux.HandleFunc("POST /api/merchants", s.merchant)
|
s.mux.HandleFunc("POST /api/merchants", s.merchant)
|
||||||
s.mux.HandleFunc("POST /api/instruments", s.instrument)
|
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/{id}/transfer", s.transfer)
|
||||||
s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction)
|
s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction)
|
||||||
s.mux.HandleFunc("POST /api/manage", s.manage)
|
s.mux.HandleFunc("POST /api/manage", s.manage)
|
||||||
@@ -294,6 +295,17 @@ func (s *Server) instrument(w http.ResponseWriter, r *http.Request) {
|
|||||||
v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveInstrument(d, b.Instrument) })
|
v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveInstrument(d, b.Instrument) })
|
||||||
respond(w, v, e)
|
respond(w, v, e)
|
||||||
}
|
}
|
||||||
|
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b struct {
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Asset domain.Asset `json:"asset"`
|
||||||
|
}
|
||||||
|
if !decode(w, r, &b) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveAsset(d, b.Asset) })
|
||||||
|
respond(w, v, e)
|
||||||
|
}
|
||||||
|
|
||||||
// transfer links or unlinks one transaction's own-account counterpart. It is a
|
// transfer links or unlinks one transaction's own-account counterpart. It is a
|
||||||
// separate endpoint because both sides change together: the transaction editor
|
// separate endpoint because both sides change together: the transaction editor
|
||||||
|
|||||||
@@ -617,6 +617,8 @@ function WealthStrip({
|
|||||||
{money(total.positions, total.currency)} in positions across{" "}
|
{money(total.positions, total.currency)} in positions across{" "}
|
||||||
{positions} investment account
|
{positions} investment account
|
||||||
{positions === 1 ? "" : "s"}
|
{positions === 1 ? "" : "s"}
|
||||||
|
{total.assets !== "0.00" &&
|
||||||
|
` · ${money(total.assets, total.currency)} in other assets`}
|
||||||
{total.unpriced > 0 &&
|
{total.unpriced > 0 &&
|
||||||
` · ${total.unpriced} holding${total.unpriced === 1 ? "" : "s"} without a quote, excluded`}
|
` · ${total.unpriced} holding${total.unpriced === 1 ? "" : "s"} without a quote, excluded`}
|
||||||
{failing > 0 &&
|
{failing > 0 &&
|
||||||
|
|||||||
+315
-5
@@ -3,12 +3,30 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CandlestickChart,
|
CandlestickChart,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Home,
|
||||||
Landmark,
|
Landmark,
|
||||||
|
Pencil,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { QuoteResult, State, Wealth, WealthAccount } from "./api";
|
import type {
|
||||||
|
QuoteResult,
|
||||||
|
State,
|
||||||
|
Wealth,
|
||||||
|
WealthAccount,
|
||||||
|
WealthAsset,
|
||||||
|
} from "./api";
|
||||||
import { money, request } from "./api";
|
import { money, request } from "./api";
|
||||||
import { Empty, ErrorMessage } from "./ui";
|
import {
|
||||||
|
DateField,
|
||||||
|
Empty,
|
||||||
|
ErrorMessage,
|
||||||
|
Field,
|
||||||
|
FormActions,
|
||||||
|
Modal,
|
||||||
|
type Mutate,
|
||||||
|
} from "./ui";
|
||||||
|
|
||||||
// The report is recomputed from the journal, so it is keyed on the revision and
|
// The report is recomputed from the journal, so it is keyed on the revision and
|
||||||
// never cached: it exists to be compared with a bank or broker's own screen.
|
// never cached: it exists to be compared with a bank or broker's own screen.
|
||||||
@@ -17,9 +35,11 @@ import { Empty, ErrorMessage } from "./ui";
|
|||||||
export default function WealthPage({
|
export default function WealthPage({
|
||||||
revision,
|
revision,
|
||||||
acceptState,
|
acceptState,
|
||||||
|
mutate,
|
||||||
}: {
|
}: {
|
||||||
revision: string;
|
revision: string;
|
||||||
acceptState: (state: State, message?: string) => void;
|
acceptState: (state: State, message?: string) => void;
|
||||||
|
mutate: Mutate;
|
||||||
}) {
|
}) {
|
||||||
const [wealth, setWealth] = useState<Wealth | null>(null);
|
const [wealth, setWealth] = useState<Wealth | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -33,7 +53,7 @@ export default function WealthPage({
|
|||||||
setError("");
|
setError("");
|
||||||
request<Wealth>("/api/wealth", undefined, controller.signal)
|
request<Wealth>("/api/wealth", undefined, controller.signal)
|
||||||
.then((value) => {
|
.then((value) => {
|
||||||
for (const key of ["accounts", "totals"] as const) {
|
for (const key of ["accounts", "assets", "totals"] as const) {
|
||||||
if (!(key in value))
|
if (!(key in value))
|
||||||
throw new Error(`Wealth response is missing ${key}.`);
|
throw new Error(`Wealth response is missing ${key}.`);
|
||||||
if (value[key] === null) Object.assign(value, { [key]: [] });
|
if (value[key] === null) Object.assign(value, { [key]: [] });
|
||||||
@@ -181,8 +201,9 @@ export default function WealthPage({
|
|||||||
Total wealth
|
Total wealth
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
Cash plus the market value of every priced holding, per
|
Cash, the market value of every priced holding, and your
|
||||||
currency, across all {wealth.accounts.length} account
|
other assets, per currency, across all{" "}
|
||||||
|
{wealth.accounts.length} account
|
||||||
{wealth.accounts.length === 1 ? "" : "s"}.
|
{wealth.accounts.length === 1 ? "" : "s"}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,6 +233,14 @@ export default function WealthPage({
|
|||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
in positions
|
in positions
|
||||||
</span>
|
</span>
|
||||||
|
{total.assets !== "0.00" && (
|
||||||
|
<span>
|
||||||
|
<strong className="money">
|
||||||
|
{money(total.assets, total.currency)}
|
||||||
|
</strong>{" "}
|
||||||
|
in other assets
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{total.unpriced > 0 && (
|
{total.unpriced > 0 && (
|
||||||
<span>
|
<span>
|
||||||
<strong>{total.unpriced}</strong> holding
|
<strong>{total.unpriced}</strong> holding
|
||||||
@@ -224,6 +253,11 @@ export default function WealthPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
<AssetsPanel
|
||||||
|
assets={wealth.assets}
|
||||||
|
currency={wealth.totals[0]?.currency ?? "EUR"}
|
||||||
|
mutate={mutate}
|
||||||
|
/>
|
||||||
{wealth.accounts.length === 0 ? (
|
{wealth.accounts.length === 0 ? (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<Empty title="No accounts to report on yet">
|
<Empty title="No accounts to report on yet">
|
||||||
@@ -476,3 +510,279 @@ function AccountReport({ account }: { account: WealthAccount }) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// AssetsPanel lists the hand-valued possessions counted into the total above
|
||||||
|
// and edits them in place: they live in the journal like any registry entity,
|
||||||
|
// but this page is where their figure matters, so this page manages them.
|
||||||
|
function AssetsPanel({
|
||||||
|
assets,
|
||||||
|
currency,
|
||||||
|
mutate,
|
||||||
|
}: {
|
||||||
|
assets: WealthAsset[];
|
||||||
|
currency: string;
|
||||||
|
mutate: Mutate;
|
||||||
|
}) {
|
||||||
|
const blank: WealthAsset = {
|
||||||
|
asset_id: "",
|
||||||
|
name: "",
|
||||||
|
kind: "",
|
||||||
|
currency,
|
||||||
|
value: "",
|
||||||
|
valued_at: new Date().toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
const [editing, setEditing] = useState<WealthAsset | null>(null);
|
||||||
|
const [removing, setRemoving] = useState<WealthAsset | null>(null);
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<h3>
|
||||||
|
<Home size={17} />
|
||||||
|
Other assets
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Possessions you value by hand — a house, a car, a private loan —
|
||||||
|
counted into the total above. A negative value records a liability
|
||||||
|
such as a mortgage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button className="button secondary" onClick={() => setEditing(blank)}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Add asset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<Empty title="No assets recorded yet">
|
||||||
|
Anything without a market feed goes here at the value you state, and
|
||||||
|
it joins the wealth figure immediately.
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Asset</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th className="numeric">Value</th>
|
||||||
|
<th>Valued on</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<tr key={asset.asset_id}>
|
||||||
|
<td>{asset.name}</td>
|
||||||
|
<td className="muted">{asset.kind || "—"}</td>
|
||||||
|
<td
|
||||||
|
className={`numeric money ${asset.value.startsWith("-") ? "text-danger" : ""}`}
|
||||||
|
>
|
||||||
|
{money(asset.value, asset.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{asset.valued_at}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label={`Edit ${asset.name}`}
|
||||||
|
onClick={() => setEditing(asset)}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button danger"
|
||||||
|
aria-label={`Delete ${asset.name}`}
|
||||||
|
onClick={() => setRemoving(asset)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="hint">
|
||||||
|
A value is what you state it is, dated so a stale estimate is
|
||||||
|
visible. Re-edit an asset when its worth changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<AssetEditor
|
||||||
|
asset={editing}
|
||||||
|
mutate={mutate}
|
||||||
|
close={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{removing && (
|
||||||
|
<DeleteAsset
|
||||||
|
asset={removing}
|
||||||
|
mutate={mutate}
|
||||||
|
close={() => setRemoving(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function AssetEditor({
|
||||||
|
asset,
|
||||||
|
mutate,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
asset: WealthAsset;
|
||||||
|
mutate: Mutate;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(asset.name);
|
||||||
|
const [kind, setKind] = useState(asset.kind || "");
|
||||||
|
const [currency, setCurrency] = useState(asset.currency);
|
||||||
|
const [value, setValue] = useState(asset.value);
|
||||||
|
const [valuedAt, setValuedAt] = useState(asset.valued_at);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return (
|
||||||
|
<Modal title={asset.asset_id ? "Edit asset" : "New asset"} close={close}>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await mutate(
|
||||||
|
"/api/assets",
|
||||||
|
{
|
||||||
|
asset: {
|
||||||
|
id: asset.asset_id,
|
||||||
|
name: name.trim(),
|
||||||
|
kind: kind.trim(),
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
value: value.trim(),
|
||||||
|
valued_at: valuedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
`${name.trim()} saved`,
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-body">
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
<Field label="Name">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Family home"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Kind" hint="Free text: Real estate, Vehicle, Loan…">
|
||||||
|
<input
|
||||||
|
maxLength={100}
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value)}
|
||||||
|
placeholder="Real estate"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Value"
|
||||||
|
hint="Your own estimate. A negative value records a liability such as a mortgage."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="-?\d+([.,]\d{1,4})?"
|
||||||
|
title="A decimal amount with up to four decimal places"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value.replace(",", "."))}
|
||||||
|
placeholder="250000"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Currency">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={3}
|
||||||
|
pattern="[A-Za-z]{3}"
|
||||||
|
title="Three-letter currency code"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<DateField
|
||||||
|
label="Valued on"
|
||||||
|
value={valuedAt}
|
||||||
|
onChange={setValuedAt}
|
||||||
|
hint="The day this estimate was made, so a stale figure is visible."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormActions
|
||||||
|
busy={busy}
|
||||||
|
close={close}
|
||||||
|
label={asset.asset_id ? "Save changes" : "Add asset"}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function DeleteAsset({
|
||||||
|
asset,
|
||||||
|
mutate,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
asset: WealthAsset;
|
||||||
|
mutate: Mutate;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
return (
|
||||||
|
<Modal title={`Delete ${asset.name}?`} close={close}>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await mutate(
|
||||||
|
"/api/manage",
|
||||||
|
{ entity: "asset", action: "delete", id: asset.asset_id },
|
||||||
|
"Asset deleted",
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-body">
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
<p>
|
||||||
|
Its {money(asset.value, asset.currency)} leaves the wealth figure
|
||||||
|
immediately. Nothing else references an asset.
|
||||||
|
</p>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Permanently delete this asset.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormActions busy={busy} close={close} label="Delete asset" />
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ export interface Instrument {
|
|||||||
quote?: string;
|
quote?: string;
|
||||||
quoted_at?: string;
|
quoted_at?: string;
|
||||||
}
|
}
|
||||||
|
// Asset is a possession valued by hand: a house, a car, anything without a
|
||||||
|
// market feed. value is what the owner states it is worth and valued_at the
|
||||||
|
// day that estimate was made. A negative value records a liability.
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind?: string;
|
||||||
|
currency: string;
|
||||||
|
value: string;
|
||||||
|
valued_at: string;
|
||||||
|
}
|
||||||
// Investment is the broker-native leg of a fact. Cash movement always stays in
|
// Investment is the broker-native leg of a fact. Cash movement always stays in
|
||||||
// Facts.amount, so a position-only event carries a zero amount. Quantity is an
|
// Facts.amount, so a position-only event carries a zero amount. Quantity is an
|
||||||
// exact signed decimal, not money: negative removes from the holding.
|
// exact signed decimal, not money: negative removes from the holding.
|
||||||
@@ -107,6 +118,7 @@ export interface Dataset {
|
|||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
merchants: Merchant[];
|
merchants: Merchant[];
|
||||||
instruments: Instrument[];
|
instruments: Instrument[];
|
||||||
|
assets: Asset[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
}
|
}
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
@@ -384,13 +396,27 @@ export interface WealthTotal {
|
|||||||
currency: string;
|
currency: string;
|
||||||
cash: string;
|
cash: string;
|
||||||
positions: string;
|
positions: string;
|
||||||
|
// assets is the stated value of every hand-valued asset in this currency,
|
||||||
|
// and wealth is cash, positions and assets together.
|
||||||
|
assets: string;
|
||||||
wealth: string;
|
wealth: string;
|
||||||
unpriced: number;
|
unpriced: number;
|
||||||
}
|
}
|
||||||
|
// WealthAsset is one hand-valued asset as the journal records it: the value is
|
||||||
|
// stated, never quoted, and carries the day it was stated.
|
||||||
|
export interface WealthAsset {
|
||||||
|
asset_id: string;
|
||||||
|
name: string;
|
||||||
|
kind?: string;
|
||||||
|
currency: string;
|
||||||
|
value: string;
|
||||||
|
valued_at: string;
|
||||||
|
}
|
||||||
// Wealth is a reconciliation report computed from the journal rather than the
|
// Wealth is a reconciliation report computed from the journal rather than the
|
||||||
// analytics index, so it can be checked against a bank or broker's own screen.
|
// analytics index, so it can be checked against a bank or broker's own screen.
|
||||||
export interface Wealth {
|
export interface Wealth {
|
||||||
accounts: WealthAccount[];
|
accounts: WealthAccount[];
|
||||||
|
assets: WealthAsset[];
|
||||||
totals: WealthTotal[];
|
totals: WealthTotal[];
|
||||||
}
|
}
|
||||||
export class APIError extends Error {
|
export class APIError extends Error {
|
||||||
@@ -461,6 +487,7 @@ export function normalizeState(state: State): State {
|
|||||||
"tags",
|
"tags",
|
||||||
"merchants",
|
"merchants",
|
||||||
"instruments",
|
"instruments",
|
||||||
|
"assets",
|
||||||
"transactions",
|
"transactions",
|
||||||
] as const) {
|
] as const) {
|
||||||
if (!(key in state.data))
|
if (!(key in state.data))
|
||||||
|
|||||||
+5
-1
@@ -402,7 +402,11 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "wealth" && (
|
{page === "wealth" && (
|
||||||
<Wealth revision={state.revision} acceptState={acceptState} />
|
<Wealth
|
||||||
|
revision={state.revision}
|
||||||
|
acceptState={acceptState}
|
||||||
|
mutate={mutate}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{page === "classification" && (
|
{page === "classification" && (
|
||||||
<Classification state={state} acceptState={acceptState} />
|
<Classification state={state} acceptState={acceptState} />
|
||||||
|
|||||||
Reference in New Issue
Block a user