Add account balance anchors
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user