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