Add account balance anchors

This commit is contained in:
Lars Nolden
2026-09-14 13:32:18 +02:00
parent 83bb86bc93
commit 71e95917da
15 changed files with 440 additions and 23 deletions
+14 -1
View File
@@ -649,7 +649,20 @@ exists to be compared with the figures a bank or broker shows on its own screen.
A cash balance equals the real balance only when the journal holds that A cash balance equals the real balance only when the journal holds that
account's complete history. A broker export does; a date-windowed bank statement account's complete history. A broker export does; a date-windowed bank statement
does not. does not. A connected cash account closes that gap with a balance anchor: after
its first successful sync, the bank's booked (CLBD) balance is captured once,
verbatim, with the day it was true, and stored on the account (anchor_balance,
anchor_date in accounts.finance). The start balance - the money from before the
recorded rows - is derived as the anchor less every movement booked through the
anchor day, and reads as the first line of the account's flow breakdown. Because
the bank's figure is stored rather than the derivation, importing older history
later corrects the start balance by itself. An available or expected balance is
never anchored: it includes pending amounts with no booked fact to subtract. The
anchor is set once and never moved by later syncs; clear it in the account's
edit form and the next successful sync captures a fresh one. Running-balance
checks are only judged after the anchor day, where the balance is observable.
Anchors are refused on investment accounts, whose broker exports carry their
complete history.
Checks that fail mean the journal disagrees with itself: row arithmetic, cash Checks that fail mean the journal disagrees with itself: row arithmetic, cash
never negative, holdings never negative. A negative holding means a position was never negative, holdings never negative. A negative holding means a position was
+2
View File
@@ -144,6 +144,8 @@ Finance Duck verifies the callback state, exchanges the returned code for a `ses
Initial synchronization requests the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Automatic synchronization then runs **twice a day**, every **12 hours** after the last successful run, overlapping each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization at any time. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV. Initial synchronization requests the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Automatic synchronization then runs **twice a day**, every **12 hours** after the last successful run, overlapping each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization at any time. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV.
**The start balance is anchored, not guessed.** Open banking shares a date-windowed history, so the sum of the recorded rows alone is not the account's real balance — the money from before the window is missing. After a connected cash account's first successful sync, Finance Duck captures the bank's **booked balance** once, with the day it was true, and stores it on the account (`anchor_balance`, `anchor_date`). **Wealth** then derives the start balance — the anchor less every movement booked through the anchor day — shows it as the first line of the account's flow breakdown, and reports the real balance. Only the booked (CLBD) figure is used, never an available balance that includes pending amounts. The anchor is set once and never moved by a later sync; importing older history corrects the derived start balance by itself, and clearing the anchor in the account's edit form makes the next sync capture a fresh one.
**HTTP 429 is a provider rate limit, not evidence that bank consent has expired.** Bank reads honor `Retry-After` and use bounded exponential retries. A longer or exhausted limit pauses further requests until the reported retry time; failed accounts keep their previous sync cursors and imported data. Session checks use the saved account metadata rather than fetching every account's details again. A failed session is reported once instead of also marking each of its accounts unavailable. After the cooldown, **Sync now** can retry; the warning clears after a successful sync. One-time authorization and code-exchange requests are never automatically replayed. **HTTP 429 is a provider rate limit, not evidence that bank consent has expired.** Bank reads honor `Retry-After` and use bounded exponential retries. A longer or exhausted limit pauses further requests until the reported retry time; failed accounts keep their previous sync cursors and imported data. Session checks use the saved account metadata rather than fetching every account's details again. A failed session is reported once instead of also marking each of its accounts unavailable. After the cooldown, **Sync now** can retry; the warning clears after a successful sync. One-time authorization and code-exchange requests are never automatically replayed.
**A rate-limited sync is a wait, not a fault.** While every failing bank has supplied a retry time, the dashboard reports that synchronization retries by itself after that moment, the account card shows a rate-limit badge instead of a connection error, and the background scheduler sleeps until the deadline rather than retrying hourly into a refusal it already knows about. **Sync now** still tries immediately. Any failure without a supplied deadline keeps the hourly retry, and its cause is named where Finance Duck can determine it locally: an expired consent, an HTTP status, an unreachable provider, or a response it cannot use, such as a booked transaction without a booking date. Provider response text is never displayed. **A rate-limited sync is a wait, not a fault.** While every failing bank has supplied a retry time, the dashboard reports that synchronization retries by itself after that moment, the account card shows a rate-limit badge instead of a connection error, and the background scheduler sleeps until the deadline rather than retrying hourly into a refusal it already knows about. **Sync now** still tries immediately. Any failure without a supplied deadline keeps the hourly retry, and its cause is named where Finance Duck can determine it locally: an expired consent, an HTTP status, an unreachable provider, or a response it cannot use, such as a booked transaction without a booking date. Provider response text is never displayed.
+58
View File
@@ -60,6 +60,64 @@ func seed(t *testing.T, a *App, s State) State {
return result.State return result.State
} }
func TestSaveAccountClearsStaleBalanceAnchorOnIdentityChange(t *testing.T) {
cases := []struct {
name string
change func(*domain.Account)
clear bool
}{
{
name: "currency",
change: func(account *domain.Account) {
account.Currency = "USD"
},
clear: true,
},
{
name: "external account",
change: func(account *domain.Account) {
account.ExternalAccountID = "new_uid"
},
clear: true,
},
{
name: "display name",
change: func(account *domain.Account) {
account.DisplayName = "Renamed"
},
clear: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a, s := testApp(t)
anchored := s.Data.Accounts[0]
anchored.ExternalAccountID = "old_uid"
anchored.AnchorBalance = "100.00"
anchored.AnchorDate = "2026-09-10"
var err error
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
return SaveAccount(d, anchored)
})
if err != nil {
t.Fatal(err)
}
changed := anchored
tc.change(&changed)
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
return SaveAccount(d, changed)
})
if err != nil {
t.Fatal(err)
}
got := s.Data.Accounts[0]
if tc.clear != (got.AnchorBalance == "" && got.AnchorDate == "") {
t.Fatalf("anchor after %s change: balance=%q date=%q", tc.name, got.AnchorBalance, got.AnchorDate)
}
})
}
}
// A released binary wrote include_amount into config.toml. Refusing it on // A released binary wrote include_amount into config.toml. Refusing it on
// startup made every upgraded deployment crash-loop against its own settings // startup made every upgraded deployment crash-loop against its own settings
// file, so a retired key must load and then disappear on the next save. // file, so a retired key must load and then disappear on the next save.
+61
View File
@@ -882,6 +882,7 @@ func (a *App) Sync(ctx context.Context) (State, error) {
} }
s = result.State s = result.State
a.ops.AccountSync[account.ID] = now.Format(time.RFC3339) a.ops.AccountSync[account.ID] = now.Format(time.RFC3339)
s = a.anchorAccount(ctx, s, account, to)
} }
a.ops.SyncError = strings.Join(failures, "; ") a.ops.SyncError = strings.Join(failures, "; ")
a.ops.SyncRetryAt = "" a.ops.SyncRetryAt = ""
@@ -897,6 +898,66 @@ func (a *App) Sync(ctx context.Context) (State, error) {
return a.snapshot(ctx) return a.snapshot(ctx)
} }
// anchorAccount fixes a connected cash account's start balance after its first
// successful sync: the bank's booked (CLBD) balance is captured once, verbatim,
// with the day it was true, so a date-windowed history still yields the real
// balance — the money from before the window is derived as the anchor less
// every movement booked through the anchor date, and an older import later
// corrects that derivation by itself. The balance is fetched after the
// transactions to minimize the gap between the two reads. Banks supply booking
// dates rather than exact times, so the anchor day is deliberately treated as
// one completed booked state. Every failure leaves the anchor unset for the
// next sync to retry; a missing CLBD figure is such a failure, because an
// available or expected balance includes pending amounts that have no booked
// fact to subtract.
func (a *App) anchorAccount(ctx context.Context, s State, account domain.Account, today string) State {
if account.Investing() || account.AnchorDate != "" || account.ExternalAccountID == "" {
return s
}
balances, err := a.bank.Balances(ctx, account.ExternalAccountID)
if err != nil {
return s
}
var selected banking.Balance
anchorDate := ""
for _, balance := range balances {
if balance.Type != "CLBD" || balance.Currency != account.Currency {
continue
}
date := balance.ReferenceDate
if date == "" {
date = today
} else if _, e := time.Parse("2006-01-02", date); e != nil || date > today {
continue
}
if date < anchorDate {
continue
}
// Two different booked figures for the same account, currency and
// reference day are ambiguous. Do not let response order decide money.
if date == anchorDate && anchorDate != "" && balance.Amount != selected.Amount {
return s
}
selected, anchorDate = balance, date
}
if anchorDate == "" {
return s
}
data := domain.Clone(s.Data)
for i := range data.Accounts {
if data.Accounts[i].ID != account.ID {
continue
}
data.Accounts[i].AnchorBalance = selected.Amount
data.Accounts[i].AnchorDate = anchorDate
if next, e := a.commit(ctx, s.Revision, data); e == nil {
return next
}
return s
}
return s
}
// syncInterval is how often connected accounts synchronize on their own. Twice // syncInterval is how often connected accounts synchronize on their own. Twice
// a day halves how long a booking can sit unseen while staying inside Enable // a day halves how long a booking can sit unseen while staying inside Enable
// Banking's documented background allowance of roughly four fetches per day per // Banking's documented background allowance of roughly four fetches per day per
+6
View File
@@ -61,6 +61,12 @@ func SaveAccount(d *domain.Dataset, v domain.Account) error {
} }
for i, x := range d.Accounts { for i, x := range d.Accounts {
if x.ID == v.ID { if x.ID == v.ID {
// A balance belongs to the account identity and currency that the
// bank reported. Changing either makes the captured figure stale;
// clear it so the next connected sync can capture a matching one.
if x.Currency != v.Currency || x.ExternalAccountID != v.ExternalAccountID {
v.AnchorBalance, v.AnchorDate = "", ""
}
d.Accounts[i] = v d.Accounts[i] = v
return nil return nil
} }
+71 -3
View File
@@ -19,6 +19,7 @@ import (
type bankScenario struct { type bankScenario struct {
session banking.Session session banking.Session
fail bool fail bool
balances []banking.Balance
} }
func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) { func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) {
@@ -41,6 +42,9 @@ func (b *bankScenario) Status(context.Context, string) (banking.SessionStatus, e
return status, nil return status, nil
} }
func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) { func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) {
if b.balances != nil {
return b.balances, nil
}
return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil
} }
func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string, _ bool) ([]domain.Facts, error) { func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string, _ bool) ([]domain.Facts, error) {
@@ -83,6 +87,53 @@ func TestSyncRestoresSavedConsentBindingsAndDoesNotDuplicateFacts(t *testing.T)
t.Fatal("provider failure was not isolated from canonical data") t.Fatal("provider failure was not isolated from canonical data")
} }
} }
// The first successful sync fixes the start balance from the bank's booked
// figure only: an available balance includes pending amounts with no booked
// fact to subtract, and a later balance change must never move an anchor that
// has been set — the anchor is the day a figure was true, not a mirror.
func TestSyncAnchorsBalanceOnceFromBookedFigureOnly(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
account.ExternalAccountID = "provider_uid"
provider := &bankScenario{
session: banking.Session{ID: "session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}},
balances: []banking.Balance{{Amount: "999.99", Currency: "EUR", Type: "ITAV"}},
}
a.bank = provider
a.ops.Sessions = []banking.Session{provider.session}
if err := a.saveOps(); err != nil {
t.Fatal(err)
}
unbooked, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if got := unbooked.Data.Accounts[0]; got.AnchorBalance != "" || got.AnchorDate != "" {
t.Fatalf("available-only balance was anchored: %+v", got)
}
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
older := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
provider.balances = append(provider.balances,
banking.Balance{Amount: "240.00", Currency: "EUR", Type: "CLBD", ReferenceDate: older},
banking.Balance{Amount: "250.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday},
)
anchored, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if got := anchored.Data.Accounts[0]; got.AnchorBalance != "250.00" || got.AnchorDate != yesterday {
t.Fatalf("booked balance was not anchored at its reference day: %+v", got)
}
provider.balances = []banking.Balance{{Amount: "300.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday}}
retained, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(anchored.Data, retained.Data) {
t.Fatal("a later balance moved an existing anchor")
}
}
func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T) { func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T) {
a, s := testApp(t) a, s := testApp(t)
account := s.Data.Accounts[0] account := s.Data.Accounts[0]
@@ -196,9 +247,16 @@ func TestSyncSessionRateLimitPreservesBindingsAndRecovers(t *testing.T) {
failures: map[string]error{}, failures: map[string]error{},
} }
a.bank = b a.bank = b
first, err := a.Sync(ctx)
if err != nil || len(first.Data.Transactions) != 4 {
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(first.Data.Transactions), err, first.Status.SyncError)
}
// The first successful sync also anchors each account's balance; a second
// sync reaches the steady state where the session bindings have absorbed
// the anchored accounts and nothing changes any more.
before, err := a.Sync(ctx) before, err := a.Sync(ctx)
if err != nil || len(before.Data.Transactions) != 4 { if err != nil || !reflect.DeepEqual(first.Data, before.Data) {
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(before.Data.Transactions), err, before.Status.SyncError) t.Fatalf("steady-state sync changed canonical data: %v", err)
} }
old := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339) old := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
a.ops.LastSync = old a.ops.LastSync = old
@@ -267,9 +325,19 @@ func TestSyncMissingMembershipStillRejectsAccount(t *testing.T) {
if len(b.accounts) != 1 || b.accounts[0].ID != "other" || a.ops.AccountSync[s.Data.Accounts[0].ID] != last || a.ops.LastSync != last { if len(b.accounts) != 1 || b.accounts[0].ID != "other" || a.ops.AccountSync[s.Data.Accounts[0].ID] != last || a.ops.LastSync != last {
t.Fatal("missing member was fetched or advanced its cursor, or valid member was skipped") t.Fatal("missing member was fetched or advanced its cursor, or valid member was skipped")
} }
if !reflect.DeepEqual(before.Accounts, after.Data.Accounts) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" { if !reflect.DeepEqual(before.Accounts[0], after.Data.Accounts[0]) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
t.Fatal("missing membership changed bindings or imported unauthorized facts") t.Fatal("missing membership changed bindings or imported unauthorized facts")
} }
// The authorized member's first successful sync anchors its balance from
// the bank's booked figure; the rejected member must not gain one.
anchored := after.Data.Accounts[1]
if anchored.AnchorBalance != "100.00" || anchored.AnchorDate == "" {
t.Fatalf("authorized member was not anchored: %+v", anchored)
}
anchored.AnchorBalance, anchored.AnchorDate = "", ""
if !reflect.DeepEqual(before.Accounts[1], anchored) {
t.Fatal("anchoring changed more than the anchor on the authorized member")
}
} }
func TestSyncTransactionFailuresPreserveProgressAndSafeErrors(t *testing.T) { func TestSyncTransactionFailuresPreserveProgressAndSafeErrors(t *testing.T) {
+60 -8
View File
@@ -49,9 +49,11 @@ type WealthAccount struct {
Records int `json:"records"` Records int `json:"records"`
FirstBooking string `json:"first_booking,omitempty"` FirstBooking string `json:"first_booking,omitempty"`
LastBooking string `json:"last_booking,omitempty"` LastBooking string `json:"last_booking,omitempty"`
// Cash is every recorded movement summed. It equals the account's real // Cash is every recorded movement summed — plus, when the account carries a
// balance only when the journal holds that account's complete history, // balance anchor, the derived start balance. Without an anchor it equals
// which a broker export does and a date-windowed bank statement does not. // the account's real balance only when the journal holds that account's
// complete history, which a broker export does and a date-windowed bank
// statement does not.
Cash domain.Money `json:"cash"` Cash domain.Money `json:"cash"`
// Positions is the market value of every priced holding, and Wealth the two // Positions is the market value of every priced holding, and Wealth the two
// together: the number this page exists to show. Unpriced counts the // together: the number this page exists to show. Unpriced counts the
@@ -192,6 +194,13 @@ func WealthOf(data domain.Dataset) Wealth {
unappliedFee, unappliedTax int64 unappliedFee, unappliedTax int64
unappliedRows int unappliedRows int
unmatchedCash, unmatchedRows int64 unmatchedCash, unmatchedRows int64
// anchored accounts carry the bank's booked balance on anchorDate.
// residual is that figure less every movement booked through the
// anchor day: the money from before the recorded history, and the
// account's derived start balance.
anchored bool
anchorDate string
residual int64
} }
states := map[string]*accountState{} states := map[string]*accountState{}
state := func(id string) *accountState { state := func(id string) *accountState {
@@ -200,15 +209,42 @@ func WealthOf(data domain.Dataset) Wealth {
} }
return states[id] return states[id]
} }
// An anchored account's balance is the bank's own figure plus what moved
// after the anchor day. The residue is order-independent, so it is settled
// before the chronological pass that judges running balances.
for _, account := range data.Accounts {
if account.AnchorDate == "" {
continue
}
anchor, err := account.AnchorBalance.Minor()
if err != nil {
continue
}
st := state(account.ID)
st.anchored, st.anchorDate, st.residual = true, account.AnchorDate, anchor
for _, t := range data.Transactions {
if t.Facts.AccountID != account.ID || t.Facts.BookingDate > account.AnchorDate {
continue
}
if minor, e := t.Facts.Amount.Minor(); e == nil {
st.residual -= minor
}
}
}
// A day's rows are applied together before any low-water mark is taken. // A day's rows are applied together before any low-water mark is taken.
// Order within a day is not knowable: a broker export states a booking date // Order within a day is not knowable: a broker export states a booking date
// and a clock time, the time is local and crosses midnight, so only the // and a clock time, the time is local and crosses midnight, so only the
// date is imported. A purchase funded by a sale nine seconds earlier then // date is imported. A purchase funded by a sale nine seconds earlier then
// arrives in an arbitrary order, and checking row by row reports a dip // arrives in an arbitrary order, and checking row by row reports a dip
// that never happened. // that never happened.
// Days on or before an anchor are not judged at all: the history before
// the anchor is incomplete by definition, so a running balance there is
// not observable.
closeDay := func(st *accountState) { closeDay := func(st *accountState) {
if st.cash < st.lowestCash { if !st.anchored || st.day > st.anchorDate {
st.lowestCash, st.lowestCashDate = st.cash, st.day if effective := st.cash + st.residual; effective < st.lowestCash {
st.lowestCash, st.lowestCashDate = effective, st.day
}
} }
for _, held := range st.holdings { for _, held := range st.holdings {
if held.units < held.lowest { if held.units < held.lowest {
@@ -317,13 +353,22 @@ func WealthOf(data domain.Dataset) Wealth {
if kind == "" { if kind == "" {
kind = domain.AccountCash kind = domain.AccountCash
} }
cash := st.cash + st.residual
entry := WealthAccount{ entry := WealthAccount{
AccountID: account.ID, DisplayName: account.DisplayName, Institution: account.Institution, AccountID: account.ID, DisplayName: account.DisplayName, Institution: account.Institution,
Currency: account.Currency, Kind: kind, Active: account.Active, Currency: account.Currency, Kind: kind, Active: account.Active,
Records: st.records, FirstBooking: st.first, LastBooking: st.last, Records: st.records, FirstBooking: st.first, LastBooking: st.last,
Cash: domain.FormatMoney(st.cash), Flows: []WealthFlow{}, Cash: domain.FormatMoney(cash), Flows: []WealthFlow{},
Holdings: []WealthHolding{}, Checks: []WealthCheck{}, Holdings: []WealthHolding{}, Checks: []WealthCheck{},
} }
// The start balance reads first, like the carried-over line on a paper
// statement, and keeps the invariant that the flows sum to the balance.
if st.anchored {
entry.Flows = append(entry.Flows, WealthFlow{
Event: "anchor", Label: "Start balance (before the recorded rows)",
Cash: domain.FormatMoney(st.residual),
})
}
for _, flow := range flowLabels { for _, flow := range flowLabels {
if moved := st.flows[flow.event]; moved != nil { if moved := st.flows[flow.event]; moved != nil {
entry.Flows = append(entry.Flows, WealthFlow{ entry.Flows = append(entry.Flows, WealthFlow{
@@ -333,7 +378,7 @@ func WealthOf(data domain.Dataset) Wealth {
} }
} }
seen(account.Currency) seen(account.Currency)
totals[account.Currency] += st.cash totals[account.Currency] += 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 {
held := st.holdings[id] held := st.holdings[id]
@@ -372,7 +417,7 @@ func WealthOf(data domain.Dataset) Wealth {
} }
slices.SortFunc(entry.Holdings, func(x, y WealthHolding) int { return strings.Compare(x.Name, y.Name) }) slices.SortFunc(entry.Holdings, func(x, y WealthHolding) int { return strings.Compare(x.Name, y.Name) })
entry.Positions, entry.Unpriced = domain.FormatMoney(positions), unpriced entry.Positions, entry.Unpriced = domain.FormatMoney(positions), unpriced
entry.Wealth = domain.FormatMoney(st.cash + positions) entry.Wealth = domain.FormatMoney(cash + positions)
positionTotals[account.Currency] += positions positionTotals[account.Currency] += positions
unpricedTotals[account.Currency] += unpriced unpricedTotals[account.Currency] += unpriced
@@ -384,8 +429,15 @@ func WealthOf(data domain.Dataset) Wealth {
} else { } else {
check("Row arithmetic", "every record agrees with its own gross, fee, tax, quantity and price", false) check("Row arithmetic", "every record agrees with its own gross, fee, tax, quantity and price", false)
} }
if st.anchored {
check("Balance anchored", fmt.Sprintf("cash is the bank's own booked balance %s on %s plus every movement after that day; the start balance line, %s, is that figure less the movements booked through it", account.AnchorBalance, st.anchorDate, domain.FormatMoney(st.residual)), false)
} else if !account.Investing() && account.ExternalAccountID != "" {
check("Balance not anchored", "cash is the recorded movements only; the next successful synchronization captures the bank's booked balance and fixes the start balance", false)
}
if st.lowestCash < 0 { if st.lowestCash < 0 {
check("Cash never negative", fmt.Sprintf("balance reached %s on %s, so the history is incomplete or a movement is misread", domain.FormatMoney(st.lowestCash), st.lowestCashDate), true) check("Cash never negative", fmt.Sprintf("balance reached %s on %s, so the history is incomplete or a movement is misread", domain.FormatMoney(st.lowestCash), st.lowestCashDate), true)
} else if st.anchored {
check("Cash never negative", "the running balance stays at or above zero from the anchor day onward; earlier days are not judged against an incomplete window", false)
} else { } else {
check("Cash never negative", "the running balance stays at or above zero throughout", false) check("Cash never negative", "the running balance stays at or above zero throughout", false)
} }
+68
View File
@@ -416,3 +416,71 @@ func TestWealthCountsHandValuedAssets(t *testing.T) {
t.Errorf("assets not echoed sorted by name with their dates: %+v", report.Assets) t.Errorf("assets not echoed sorted by name with their dates: %+v", report.Assets)
} }
} }
// A bank's date-windowed history starts mid-life, so an anchored account
// derives its start balance: the bank's booked figure on the anchor day less
// everything booked through it. The derived line keeps the flows summing to
// the balance, and the pre-anchor window is never judged as an overdraft —
// the history there is incomplete by definition.
func TestAnchoredAccountDerivesStartBalance(t *testing.T) {
data := domain.NewDataset()
data.Accounts = []domain.Account{
{ID: "acc_anchored", DisplayName: "Checking", Currency: "EUR", Active: true, ExternalAccountID: "uid_one", AnchorBalance: "2450.00", AnchorDate: "2026-09-10"},
{ID: "acc_plain", DisplayName: "Connected", Currency: "EUR", Active: true, ExternalAccountID: "uid_two"},
}
row := func(id, account, date string, amount domain.Money) domain.Transaction {
f := domain.Facts{ID: id, Source: "enablebanking", AccountID: account, BookingDate: date, Amount: amount, Currency: "EUR", RawDescription: id, Fingerprint: "fp_" + id}
return domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)}
}
data.Transactions = []domain.Transaction{
// The recorded window alone would dip to 900 before the anchor day.
row("tx_pre", "acc_anchored", "2026-09-01", "-900.00"),
row("tx_on", "acc_anchored", "2026-09-10", "50.00"),
row("tx_post", "acc_anchored", "2026-09-12", "-100.00"),
row("tx_other", "acc_plain", "2026-09-12", "10.00"),
}
if err := domain.Validate(data); err != nil {
t.Fatal(err)
}
report := WealthOf(data)
anchored := report.Accounts[0]
// 2450.00 on 2026-09-10 less the 850.00 booked through that day puts
// 3300.00 before the window; the balance is 2450.00 100.00 booked after.
if anchored.Cash != "2350.00" || anchored.Wealth != "2350.00" {
t.Errorf("anchored cash %s wealth %s, want 2350.00", anchored.Cash, anchored.Wealth)
}
if len(anchored.Flows) == 0 || anchored.Flows[0].Event != "anchor" || anchored.Flows[0].Cash != "3300.00" {
t.Errorf("start balance line missing or wrong: %+v", anchored.Flows)
}
total := int64(0)
for _, flow := range anchored.Flows {
cash, err := flow.Cash.Minor()
if err != nil {
t.Fatal(err)
}
total += cash
}
if domain.FormatMoney(total) != anchored.Cash {
t.Errorf("flows sum to %s, balance is %s", domain.FormatMoney(total), anchored.Cash)
}
checks := map[string]WealthCheck{}
for _, check := range anchored.Checks {
checks[check.Name] = check
}
if _, ok := checks["Balance anchored"]; !ok {
t.Errorf("no anchor note: %+v", anchored.Checks)
}
if check := checks["Cash never negative"]; check.Failed {
t.Errorf("pre-anchor window judged as an overdraft: %s", check.Detail)
}
note := false
for _, check := range report.Accounts[1].Checks {
note = note || check.Name == "Balance not anchored"
}
if !note {
t.Errorf("connected account without an anchor carries no note: %+v", report.Accounts[1].Checks)
}
if report.Totals[0].Cash != "2360.00" {
t.Errorf("total cash %s, want 2360.00", report.Totals[0].Cash)
}
}
+14
View File
@@ -291,6 +291,20 @@ func Validate(d Dataset) error {
if a.Kind != "" && a.Kind != AccountCash && a.Kind != AccountInvestment { if a.Kind != "" && a.Kind != AccountCash && a.Kind != AccountInvestment {
return fmt.Errorf("account %q: kind must be %q or %q", a.ID, AccountCash, AccountInvestment) return fmt.Errorf("account %q: kind must be %q or %q", a.ID, AccountCash, AccountInvestment)
} }
// An anchor is one figure and the day it was true: neither half means
// anything alone, and anchoring an investment account would mask an
// incomplete broker history instead of exposing it.
if (a.AnchorBalance == "") != (a.AnchorDate == "") {
return fmt.Errorf("account %q: an anchor needs both a balance and its date", a.ID)
}
if a.AnchorDate != "" {
if a.Investing() {
return fmt.Errorf("account %q: a balance anchor belongs to a cash account; a broker export carries its complete history", a.ID)
}
if _, err := a.AnchorBalance.Minor(); err != nil || !validDate(a.AnchorDate) {
return fmt.Errorf("account %q: invalid anchor balance or date", a.ID)
}
}
accounts[a.ID] = a accounts[a.ID] = a
} }
for _, c := range d.Categories { for _, c := range d.Categories {
+12
View File
@@ -77,6 +77,18 @@ func TestDomainRejectsBrokenReferencesAndTaxonomy(t *testing.T) {
{"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }}, {"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }},
{"oversized tag name", func(d *Dataset) { d.Tags[0].Name = strings.Repeat("x", 201) }}, {"oversized tag name", func(d *Dataset) { d.Tags[0].Name = strings.Repeat("x", 201) }},
{"oversized category name", func(d *Dataset) { d.Categories[2].Name = strings.Repeat("x", 201) }}, {"oversized category name", func(d *Dataset) { d.Categories[2].Name = strings.Repeat("x", 201) }},
{"anchor balance without its date", func(d *Dataset) { d.Accounts[1].AnchorBalance = "100.00" }},
{"anchor date without its balance", func(d *Dataset) { d.Accounts[1].AnchorDate = "2026-01-01" }},
{"anchored investment account", func(d *Dataset) {
d.Accounts[1].Kind = AccountInvestment
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "100.00", "2026-01-01"
}},
{"invalid anchor date", func(d *Dataset) {
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "100.00", "2026-02-30"
}},
{"invalid anchor balance", func(d *Dataset) {
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "1e2", "2026-01-01"
}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
+10
View File
@@ -31,6 +31,16 @@ type Account struct {
// broker exports no counterparty column, so deposits and withdrawals carry // broker exports no counterparty column, so deposits and withdrawals carry
// this IBAN instead and pair with the funding account like any transfer. // this IBAN instead and pair with the funding account like any transfer.
ReferenceIBAN string `json:"reference_iban,omitempty"` ReferenceIBAN string `json:"reference_iban,omitempty"`
// AnchorBalance is the bank's booked (CLBD) balance on AnchorDate, captured
// once from open banking after a sync. It fixes the start balance of a
// date-windowed history: the money that existed before the recorded rows is
// AnchorBalance less every movement booked through AnchorDate, so the
// account's real balance is computable without complete history. The bank's
// figure is stored verbatim — the start balance is derived, never stored —
// so importing older history later corrects the derivation by itself.
// Cash accounts only: a broker export carries its complete history.
AnchorBalance Money `json:"anchor_balance,omitempty"`
AnchorDate string `json:"anchor_date,omitempty"`
Active bool `json:"active"` Active bool `json:"active"`
} }
+42 -6
View File
@@ -1244,9 +1244,16 @@ function AccountEditor({
pattern="[A-Z]{3}" pattern="[A-Z]{3}"
maxLength={3} maxLength={3}
value={value.currency} value={value.currency}
onChange={(e) => onChange={(e) => {
setValue({ ...value, currency: e.target.value.toUpperCase() }) const currency = e.target.value.toUpperCase();
} setValue((current) => ({
...current,
currency,
...(currency !== current.currency
? { anchor_balance: "", anchor_date: "" }
: {}),
}));
}}
/> />
</Field> </Field>
</div> </div>
@@ -1288,11 +1295,40 @@ function AccountEditor({
> >
<input <input
value={value.external_account_id || ""} value={value.external_account_id || ""}
onChange={(e) => onChange={(e) => {
setValue({ ...value, external_account_id: e.target.value }) const external = e.target.value;
} setValue((current) => ({
...current,
external_account_id: external,
...(external !== (current.external_account_id || "")
? { anchor_balance: "", anchor_date: "" }
: {}),
}));
}}
/> />
</Field> </Field>
{value.anchor_date && (
<Field
label="Balance anchor"
hint="The bank's booked balance, captured once after a sync. It fixes this account's start balance on Wealth. Clear it and the next synchronization captures a fresh one."
>
<div className="anchor-row">
<span>
{money(value.anchor_balance ?? "0", value.currency)} on{" "}
{value.anchor_date}
</span>
<button
type="button"
className="button subtle"
onClick={() =>
setValue({ ...value, anchor_balance: "", anchor_date: "" })
}
>
Clear anchor
</button>
</div>
</Field>
)}
<label className="checkbox"> <label className="checkbox">
<input <input
type="checkbox" type="checkbox"
+9 -3
View File
@@ -309,8 +309,11 @@ export default function WealthPage({
<dt>Completeness</dt> <dt>Completeness</dt>
<dd> <dd>
Cash equals the real balance only when the journal holds Cash equals the real balance only when the journal holds
that account's full history: a broker export does, a that account&rsquo;s full history: a broker export does, a
date-windowed bank statement does not. date-windowed bank statement does not. A connected bank
account closes that gap with an anchor the bank&rsquo;s
own booked balance, captured once from which the start
balance before the recorded rows is derived.
</dd> </dd>
</div> </div>
</dl> </dl>
@@ -547,7 +550,10 @@ function AssetsPanel({
</p> </p>
</div> </div>
<div className="row-actions"> <div className="row-actions">
<button className="button secondary" onClick={() => setEditing(blank)}> <button
className="button secondary"
onClick={() => setEditing(blank)}
>
<Plus size={16} /> <Plus size={16} />
Add asset Add asset
</button> </button>
+5
View File
@@ -11,6 +11,11 @@ export interface Account {
// against: a broker export carries no counterparty, so its deposits and // against: a broker export carries no counterparty, so its deposits and
// withdrawals pair with the funding account through this IBAN. // withdrawals pair with the funding account through this IBAN.
reference_iban?: string; reference_iban?: string;
// anchor_balance is the bank's booked balance on anchor_date, captured once
// from open banking after a sync. It fixes the start balance of a
// date-windowed history; clearing both lets the next sync re-anchor.
anchor_balance?: string;
anchor_date?: string;
active: boolean; active: boolean;
} }
// Instrument is a security held in an investment account. The ISIN is the // Instrument is a security held in an investment account. The ISIN is the
+6
View File
@@ -2675,3 +2675,9 @@ footer span:first-child {
font-size: 10px; font-size: 10px;
} }
} }
.anchor-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}