diff --git a/internal/analysis/ledger.go b/internal/analysis/ledger.go index e722c81..4eeb258 100644 --- a/internal/analysis/ledger.go +++ b/internal/analysis/ledger.go @@ -4,6 +4,7 @@ import ( "context" "time" + "spendbot/internal/analytics" "spendbot/internal/clickhouse" "spendbot/internal/store" ) @@ -40,3 +41,22 @@ func LedgerFromClickHouse(ctx context.Context, ch *clickhouse.Client, loc *time. } return out, nil } + +// MarkPassThrough leaves out of spending the cash that only passed through the card, unless +// the person turned that off; every reader of the ledger calls it, so pages and insights +// count the same. +func MarkPassThrough(ctx context.Context, st *store.Store, rows []store.LedgerRow) ([]store.LedgerRow, error) { + on, err := st.PassThroughCash(ctx) + if err != nil || !on { + return rows, err + } + names, err := st.IncomeSources(ctx) + if err != nil { + return nil, err + } + sources := map[string]bool{} + for _, n := range names { + sources[n] = true + } + return analytics.MarkPassThrough(rows, sources), nil +} diff --git a/internal/analysis/runner.go b/internal/analysis/runner.go index 594a21a..fc35197 100644 --- a/internal/analysis/runner.go +++ b/internal/analysis/runner.go @@ -54,6 +54,14 @@ func New(st *store.Store, ch *clickhouse.Client, cls *classify.Classifier, provi // ledger returns operations for insights: from ClickHouse when configured and reachable, // otherwise from SQLite. The computation is the same, the source does not change the numbers. func (a *Analyzer) ledger(ctx context.Context) ([]store.LedgerRow, error) { + rows, err := a.rawLedger(ctx) + if err != nil { + return nil, err + } + return MarkPassThrough(ctx, a.st, rows) +} + +func (a *Analyzer) rawLedger(ctx context.Context) ([]store.LedgerRow, error) { if a.ch != nil { rows, err := LedgerFromClickHouse(ctx, a.ch, a.opts.Location) if err == nil { diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 4e549f0..1042f8e 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -29,11 +29,12 @@ const ( ) // IsSpend reports spending: a purchase (refunds negative) or a cash withdrawal in tenge, -// unless the person skipped it. Transfers to people, top-ups and incoming money are not -// spending — except transfers the person gave a category (rent and such): those are done. -// Wallet payments and manual entries have no statement kind. +// unless the person skipped it or the cash only passed through the card (MarkPassThrough). +// Transfers to people, top-ups and incoming money are not spending — except transfers the +// person gave a category (rent and such): those are done. Wallet payments and manual entries +// have no statement kind. func IsSpend(r store.LedgerRow) bool { - if r.Currency != "KZT" || r.Status == store.StatusIgnored { + if r.Currency != "KZT" || r.Status == store.StatusIgnored || r.PassThrough > 0 && r.Kind == cash { return false } switch r.Kind { diff --git a/internal/analytics/explore.go b/internal/analytics/explore.go index e9b0a7a..a2810d4 100644 --- a/internal/analytics/explore.go +++ b/internal/analytics/explore.go @@ -196,6 +196,7 @@ type Op struct { Full int64 // the whole operation Categories []string Spend bool + PassThrough bool // cash that only passed through the card } // Editable reports whether the category of the operation can be changed: purchases, cash @@ -278,7 +279,7 @@ func Operations(all []store.LedgerRow, f Filter) Ops { } o := Op{TxID: r.TxID, At: r.At, Merchant: merchantName(r), MerchantKey: merchantKey(r), Kind: r.Kind, Status: r.Status, Source: r.Source, Amount: amount, Full: r.Amount, Categories: r.Categories, - Spend: spend || IsSpend(r)} + Spend: spend || IsSpend(r), PassThrough: r.PassThrough > 0 && r.Kind == cash} if len(terms) > 0 && !matches(o, terms) { continue } diff --git a/internal/analytics/income.go b/internal/analytics/income.go index 5adfe46..d8246e9 100644 --- a/internal/analytics/income.go +++ b/internal/analytics/income.go @@ -110,6 +110,8 @@ type Flow struct { Deficit int // months that ended in the red Basis string People bool + Passed int64 // cash that only passed through the card, left out of Out + PassedN int // withdrawals of it } // DeclaredFor sums the stated salaries covering a month ('YYYY-MM'); an open end runs on. @@ -138,6 +140,7 @@ func CurrentSalary(ps []store.SalaryPeriod, month string) (amount int64, current // CashFlow adds up income and spending by month. func CashFlow(all []store.LedgerRow, f FlowFilter) Flow { fl := Flow{Basis: f.Basis, People: f.People} + fl.Passed, fl.PassedN = PassedThrough(all, f.Period) if fl.Basis != BasisDeclared && fl.Basis != BasisCard { fl.Basis = BasisAuto } @@ -173,7 +176,7 @@ func CashFlow(all []store.LedgerRow, f FlowFilter) Flow { case r.Kind == kaspi.TopUp && r.Amount < 0 && f.Sources[r.Merchant]: m.Other -= r.Amount case r.Kind == kaspi.TopUp && r.Amount < 0 && IsPerson(r.Merchant): - m.PeopleIn -= r.Amount + m.PeopleIn -= r.Amount + r.PassThrough // what left as cash is neither spending nor theirs to repay case r.Kind == kaspi.Transfer && r.Status != store.StatusDone && IsPerson(r.Merchant): m.PeopleOut += r.Amount } diff --git a/internal/analytics/passthrough.go b/internal/analytics/passthrough.go new file mode 100644 index 0000000..4479552 --- /dev/null +++ b/internal/analytics/passthrough.go @@ -0,0 +1,118 @@ +package analytics + +import ( + "sort" + "time" + + "spendbot/internal/kaspi" + "spendbot/internal/store" +) + +// PassWindow is how long money put on the card may stay there and still count as passing +// through when it leaves as cash. +const PassWindow = 7 * 24 * time.Hour + +// MarkPassThrough marks cash that only passed through the card: money put on it that is not +// income — the person's own money from another bank, a cash deposit, money from a person — +// and taken out as cash within PassWindow. Such a withdrawal is not spending: whoever cashes +// out millions for someone else or moves money between banks through an ATM did not spend it, +// and counting it would inflate every number built on spending. The top-ups that paid for it +// carry the part of them that left, so it is not counted as money from people either. +// +// A withdrawal counts only when the money put on the card before it covers it whole; one with +// a category the person gave stays spending. sources are top-up senders counted as income: +// their money is not passing through. Rows are marked in place and returned. +func MarkPassThrough(rows []store.LedgerRow, sources map[string]bool) []store.LedgerRow { + order := make([]int, 0, len(rows)) + for i, r := range rows { + rows[i].PassThrough = 0 + if r.Currency == "KZT" && r.Status != store.StatusIgnored && (isFunding(r, sources) || isPassCash(r)) { + order = append(order, i) + } + } + sort.SliceStable(order, func(a, b int) bool { + ra, rb := rows[order[a]], rows[order[b]] + if !ra.At.Equal(rb.At) { + return ra.At.Before(rb.At) + } + // statements give dates without times: money in on a day comes before cash out of it + if (ra.Amount < 0) != (rb.Amount < 0) { + return ra.Amount < 0 + } + return ra.TxID < rb.TxID + }) + type fund struct { + i int + left int64 + } + var pool []fund // oldest first + for _, i := range order { + r := rows[i] + if r.Amount < 0 { + pool = append(pool, fund{i: i, left: -r.Amount}) + continue + } + since := r.At.Add(-PassWindow) + for len(pool) > 0 && rows[pool[0].i].At.Before(since) { + pool = pool[1:] + } + var have int64 + for _, f := range pool { + have += f.left + } + if have < r.Amount { + continue + } + rows[i].PassThrough = r.Amount + need := r.Amount + for need > 0 { + f := &pool[0] + take := min(f.left, need) + f.left -= take + rows[f.i].PassThrough += take + need -= take + if f.left == 0 { + pool = pool[1:] + } + } + } + return rows +} + +// isFunding reports money put on the card that is not income and may leave again as cash. +func isFunding(r store.LedgerRow, sources map[string]bool) bool { + return r.Kind == kaspi.TopUp && r.Amount < 0 && !IsSalary(r) && !sources[r.Merchant] && + (IsOwnMoney(r.Merchant) || IsPerson(r.Merchant)) +} + +// isPassCash reports a cash withdrawal that may have only passed through. +func isPassCash(r store.LedgerRow) bool { + return r.Kind == cash && r.Amount > 0 && len(r.Categories) == 0 +} + +// PassedThrough sums the cash withdrawals in p that passed through the card. +func PassedThrough(rows []store.LedgerRow, p Period) (sum int64, n int) { + for _, r := range rows { + if r.PassThrough > 0 && r.Kind == cash && p.contains(r.At) { + sum += r.Amount + n++ + } + } + return sum, n +} + +// Withdrawals counts the cash withdrawals in p without a category: those that count and +// those the person skipped — what store.SkipWithdrawals and CountWithdrawals would change. +func Withdrawals(rows []store.LedgerRow, p Period) (counted, skipped int) { + for _, r := range rows { + if r.Kind != cash || r.Amount <= 0 || len(r.Categories) > 0 || !p.contains(r.At) { + continue + } + if r.Status == store.StatusIgnored { + skipped++ + } else { + counted++ + } + } + return counted, skipped +} diff --git a/internal/analytics/passthrough_test.go b/internal/analytics/passthrough_test.go new file mode 100644 index 0000000..b64aac8 --- /dev/null +++ b/internal/analytics/passthrough_test.go @@ -0,0 +1,85 @@ +package analytics_test + +import ( + "testing" + "time" + + . "spendbot/internal/analytics" + "spendbot/internal/kaspi" + "spendbot/internal/store" +) + +func passRow(id int64, at time.Time, kind, details string, amount int64) store.LedgerRow { + return store.LedgerRow{TxID: id, At: at, Amount: amount * tg, Currency: "KZT", Merchant: details, + Kind: kind, Status: store.StatusInfo, Source: store.SourceImport} +} + +func passCash(id int64, at time.Time, amount int64) store.LedgerRow { + r := passRow(id, at, kaspi.Withdrawal, "", amount) + r.Status = store.StatusReview + return r +} + +func TestMarkPassThrough(t *testing.T) { + day0 := time.Date(2026, 3, 2, 0, 0, 0, 0, almaty) // statements give dates only + rows := []store.LedgerRow{ + // covered whole by own money from another bank the day before + passRow(1, day0, kaspi.TopUp, "С карты другого банка", -1_000_000), + passCash(2, day0.AddDate(0, 0, 1), 1_000_000), + // a cash deposit and its withdrawal on one day; the withdrawal is listed first + passCash(3, day0.AddDate(0, 0, 3), 200_000), + passRow(4, day0.AddDate(0, 0, 3), kaspi.TopUp, "В Kaspi Банкомате", -200_000), + // salary is income: cash from it is spending + passRow(5, day0.AddDate(0, 0, 10), kaspi.TopUp, kaspi.Salary, -500_000), + passCash(6, day0.AddDate(0, 0, 10), 100_000), + // money from a person, partly cashed out + passRow(7, day0.AddDate(0, 0, 20), kaspi.TopUp, "Aigerim A.", -300_000), + passCash(8, day0.AddDate(0, 0, 21), 120_000), + // not covered whole: stays spending, and does not use up the money before it + passCash(9, day0.AddDate(0, 0, 22), 400_000), + // money put on the card too long ago does not cover it + passRow(10, day0.AddDate(0, 1, 0), kaspi.TopUp, "С карты другого банка", -50_000), + passCash(11, day0.AddDate(0, 1, 8), 50_000), + // a withdrawal the person gave a category stays spending + passRow(12, day0.AddDate(0, 2, 0), kaspi.TopUp, "С карты другого банка", -70_000), + passCash(13, day0.AddDate(0, 2, 0), 70_000), + } + rows[len(rows)-1].Categories, rows[len(rows)-1].Splits = []string{"Rent"}, []int64{70_000 * tg} + MarkPassThrough(rows, nil) + + want := map[int64]int64{1: 1_000_000, 2: 1_000_000, 3: 200_000, 4: 200_000, 7: 120_000, 8: 120_000} + for _, r := range rows { + if r.PassThrough != want[r.TxID]*tg { + t.Errorf("tx %d: passed %d, want %d", r.TxID, r.PassThrough/tg, want[r.TxID]) + } + if r.Kind == kaspi.Withdrawal && IsSpend(r) == (want[r.TxID] > 0) { + t.Errorf("tx %d: spending %v", r.TxID, IsSpend(r)) + } + } + all := Period{From: day0, To: day0.AddDate(1, 0, 0)} + if sum, n := PassedThrough(rows, all); sum != 1_320_000*tg || n != 3 { + t.Errorf("passed through %d in %d", sum/tg, n) + } + // spending and its drill-downs leave the passed cash out + d := Build(rows, all, nil) + if d.Totals.Cash != (100_000+400_000+50_000+70_000)*tg { + t.Errorf("cash %d", d.Totals.Cash/tg) + } + if ops := Operations(rows, Filter{Period: all, Merchant: CashKey}); ops.Total != d.Totals.Cash { + t.Errorf("cash operations %d", ops.Total/tg) + } + // money from a person that left as cash is not money from people + fl := CashFlow(rows, FlowFilter{Period: all, People: true}) + if fl.PeopleIn != 180_000*tg || fl.Passed != 1_320_000*tg || fl.PassedN != 3 { + t.Errorf("people in %d, passed %d in %d", fl.PeopleIn/tg, fl.Passed/tg, fl.PassedN) + } + if counted, skipped := Withdrawals(rows, all); counted != 6 || skipped != 0 { + t.Errorf("withdrawals %d counted, %d skipped", counted, skipped) + } + + // an income source is not passing through, and marking again starts over + MarkPassThrough(rows, map[string]bool{"С карты другого банка": true}) + if rows[0].PassThrough != 0 || rows[1].PassThrough != 0 || !IsSpend(rows[1]) { + t.Errorf("income source: %+v %+v", rows[0], rows[1]) + } +} diff --git a/internal/store/edit.go b/internal/store/edit.go index 7a1797e..e8a0ed0 100644 --- a/internal/store/edit.go +++ b/internal/store/edit.go @@ -162,3 +162,55 @@ func (s *Store) MergeCategory(ctx context.Context, from, into int64) error { return nil }) } + +const keyPassThrough = "cash_pass_through" + +// PassThroughCash reports whether cash that only passed through the card is left out of +// spending (analytics.MarkPassThrough). On unless the person turned it off. +func (s *Store) PassThroughCash(ctx context.Context) (bool, error) { + v, err := s.GetKV(ctx, keyPassThrough) + return v != "off", err +} + +func (s *Store) SetPassThroughCash(ctx context.Context, on bool) error { + v := "on" + if !on { + v = "off" + } + return s.SetKV(ctx, keyPassThrough, v) +} + +// SkipWithdrawals marks the cash withdrawals in [from, to) skipped — not spending — and returns +// how many it changed. Withdrawals with a category the person gave are left alone; questions +// about the skipped ones are dropped, as for one skipped operation. +func (s *Store) SkipWithdrawals(ctx context.Context, from, to time.Time) (int, error) { + var n int64 + err := s.inTx(ctx, func(tx *sql.Tx) error { + const which = `kind = ? AND status != ? AND amount_minor > 0 AND occurred_at >= ? AND occurred_at < ? + AND NOT EXISTS (SELECT 1 FROM splits WHERE tx_id = transactions.id)` + args := []any{kaspi.Withdrawal, StatusIgnored, fmtTime(from), fmtTime(to)} + if _, err := tx.ExecContext(ctx, "DELETE FROM questions WHERE tx_id IN (SELECT id FROM transactions WHERE "+which+")", args...); err != nil { + return err + } + res, err := tx.ExecContext(ctx, "UPDATE transactions SET status = ? WHERE "+which, append([]any{StatusIgnored}, args...)...) + if err != nil { + return err + } + n, err = res.RowsAffected() + return err + }) + return int(n), err +} + +// CountWithdrawals undoes SkipWithdrawals: skipped cash withdrawals in [from, to) are +// spending again, waiting for a category as when they were imported. +func (s *Store) CountWithdrawals(ctx context.Context, from, to time.Time) (int, error) { + res, err := s.db.ExecContext(ctx, `UPDATE transactions SET status = ? + WHERE kind = ? AND status = ? AND amount_minor > 0 AND occurred_at >= ? AND occurred_at < ?`, + StatusReview, kaspi.Withdrawal, StatusIgnored, fmtTime(from), fmtTime(to)) + if err != nil { + return 0, err + } + n, err := res.RowsAffected() + return int(n), err +} diff --git a/internal/store/edit_test.go b/internal/store/edit_test.go index d40bf8d..11dbb88 100644 --- a/internal/store/edit_test.go +++ b/internal/store/edit_test.go @@ -100,3 +100,49 @@ func TestMergeCategory(t *testing.T) { } } } + +func TestSkipWithdrawals(t *testing.T) { + s := openTest(t) + ctx := context.Background() + add := func(key string, days int, kind, status string) int64 { + tx := walletTx(key, 50000) + tx.OccurredAt = now.AddDate(0, 0, days) + tx.Source, tx.Kind, tx.Status = SourceImport, kind, status + id, _, err := s.InsertTx(ctx, tx) + if err != nil { + t.Fatal(err) + } + return id + } + review := add("a", 0, kaspi.Withdrawal, StatusReview) + asked := add("b", 1, kaspi.Withdrawal, StatusAsked) + given := add("c", 2, kaspi.Withdrawal, StatusPending) + s.CloseTx(ctx, given, []Split{{CategoryID: mustCat(t, s, "Home"), AmountMinor: 50000}}, false, now) + later := add("d", 30, kaspi.Withdrawal, StatusReview) + purchase := add("e", 0, kaspi.Purchase, StatusReview) + + from, to := now.AddDate(0, 0, -1), now.AddDate(0, 0, 7) + if n, err := s.SkipWithdrawals(ctx, from, to); err != nil || n != 2 { + t.Fatalf("skipped %d, %v", n, err) + } + status := func(id int64) string { + tx, _ := s.GetTx(ctx, id) + return tx.Status + } + for id, want := range map[int64]string{review: StatusIgnored, asked: StatusIgnored, given: StatusDone, later: StatusReview, purchase: StatusReview} { + if got := status(id); got != want { + t.Errorf("tx %d: %s, want %s", id, got, want) + } + } + if n, err := s.CountWithdrawals(ctx, from, to); err != nil || n != 2 || status(asked) != StatusReview { + t.Fatalf("counted %d, %v, %s", n, err, status(asked)) + } + + if on, err := s.PassThroughCash(ctx); err != nil || !on { + t.Fatalf("pass-through is on by default: %v %v", on, err) + } + s.SetPassThroughCash(ctx, false) + if on, _ := s.PassThroughCash(ctx); on { + t.Fatal("turned off") + } +} diff --git a/internal/store/ledger.go b/internal/store/ledger.go index 62bd144..f726edb 100644 --- a/internal/store/ledger.go +++ b/internal/store/ledger.go @@ -20,6 +20,9 @@ type LedgerRow struct { Source string Categories []string // part categories in order; empty — uncategorized Splits []int64 // part amounts + // PassThrough is the part of the amount, positive, that only passed through the card as + // cash (analytics.MarkPassThrough): a whole withdrawal, or what of a top-up paid for one. + PassThrough int64 } // Ledger returns all operations from SQLite. Personal finance means tens of thousands of diff --git a/internal/web/cash.go b/internal/web/cash.go new file mode 100644 index 0000000..4509a31 --- /dev/null +++ b/internal/web/cash.go @@ -0,0 +1,81 @@ +package web + +import ( + "fmt" + "net/http" + "time" + + "spendbot/internal/analytics" + "spendbot/internal/store" +) + +// Cash that only passes through the card: the operations page, filtered to cash, shows what +// was left out and lets the person turn the rule off or skip a period's withdrawals by hand. + +type cashBox struct { + On bool // pass-through cash is left out + Passed int64 // left out in the period + PassedN int + Counted int // withdrawals without a category that are not skipped, passed through or not + Skipped int + From, To string // the period, inclusive, YYYY-MM-DD +} + +func (s *Server) cashBox(r *http.Request, rows []store.LedgerRow, p analytics.Period) (*cashBox, error) { + on, err := s.st.PassThroughCash(r.Context()) + if err != nil { + return nil, err + } + b := &cashBox{On: on, From: p.From.Format("2006-01-02"), To: p.To.AddDate(0, 0, -1).Format("2006-01-02")} + b.Passed, b.PassedN = analytics.PassedThrough(rows, p) + b.Counted, b.Skipped = analytics.Withdrawals(rows, p) + return b, nil +} + +// cashAction turns the pass-through rule on or off (action=passthrough, on=1|0) or skips the +// cash withdrawals of a period or counts them again (action=skip|count, from, to inclusive). +func (s *Server) cashAction(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + back := safeBack(r.PostFormValue("back"), "/ui/operations") + var msg string + switch r.PostFormValue("action") { + case "passthrough": + on := r.PostFormValue("on") == "1" + if err := s.st.SetPassThroughCash(ctx, on); err != nil { + s.fail(w, err) + return + } + msg = "Cash that only passed through the card is counted as spending again." + if on { + msg = "Cash that only passed through the card is not counted as spending." + } + case "skip", "count": + from, err1 := time.ParseInLocation("2006-01-02", r.PostFormValue("from"), s.loc) + to, err2 := time.ParseInLocation("2006-01-02", r.PostFormValue("to"), s.loc) + if err1 != nil || err2 != nil || to.Before(from) { + http.Error(w, "invalid period", http.StatusBadRequest) + return + } + to = to.AddDate(0, 0, 1) + var n int + var err error + if r.PostFormValue("action") == "skip" { + n, err = s.st.SkipWithdrawals(ctx, from, to) + msg = fmt.Sprintf("%d cash %s skipped: not spending.", n, plural(n, "withdrawal", "withdrawals")) + } else { + n, err = s.st.CountWithdrawals(ctx, from, to) + msg = fmt.Sprintf("%d cash %s counted as spending again.", n, plural(n, "withdrawal", "withdrawals")) + } + if err != nil { + s.fail(w, err) + return + } + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + if s.kick != nil { + s.kick.Kick() + } + http.Redirect(w, r, withMsg(back, msg), http.StatusSeeOther) +} diff --git a/internal/web/cash_test.go b/internal/web/cash_test.go new file mode 100644 index 0000000..7bcca52 --- /dev/null +++ b/internal/web/cash_test.go @@ -0,0 +1,74 @@ +package web + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "spendbot/internal/kaspi" + "spendbot/internal/store" +) + +func TestPassThroughCash(t *testing.T) { + h, st, k := setup(t) + ctx := context.Background() + _, cash := seedOps(t, st) + // own money from another bank the day before the ATM withdrawal of 20,000 ₸ + at := time.Date(2026, 8, 3, 12, 0, 0, 0, almaty) + if _, _, err := st.InsertTx(ctx, store.Tx{ExternalKey: "own", OccurredAt: at, AmountMinor: -2000000, Currency: "KZT", + MerchantRaw: "С карты другого банка", Kind: kaspi.TopUp, Source: store.SourceImport, Status: store.StatusInfo, CreatedAt: at}); err != nil { + t.Fatal(err) + } + has := func(path, want string) bool { + t.Helper() + w := do(h, "GET", path, nil, "", true) + if w.Code != http.StatusOK || !strings.HasSuffix(strings.TrimSpace(w.Body.String()), "") { + t.Fatalf("%s: %d\n%s", path, w.Code, tail(w.Body.String())) + } + return strings.Contains(w.Body.String(), want) + } + for path, want := range map[string]string{ + "/ui/analytics?period=all": "Passed through", + "/ui/income?period=all": "only passed through the card", + "/ui/operations?m=%23cash&period=all": "is not counted as spending", + "/ui/operations?type=all&period=all": "passed through", + } { + if !has(path, want) { + t.Errorf("%s lacks %q", path, want) + } + } + + // turned off, every withdrawal is spending again + w := post(h, "/ui/cash", url.Values{"action": {"passthrough"}, "on": {"0"}, "back": {"/ui/operations?m=%23cash"}}) + if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/ui/operations?") || k.n != 1 { + t.Fatalf("switch: %d %s", w.Code, w.Header().Get("Location")) + } + if on, _ := st.PassThroughCash(ctx); on { + t.Fatal("still on") + } + if has("/ui/analytics?period=all", "Passed through") || !has("/ui/operations?m=%23cash&period=all", "Every cash withdrawal counts") { + t.Error("off: cash still left out") + } + + // skipped by hand for a period, and counted again + period := url.Values{"from": {"2026-08-01"}, "to": {"2026-08-31"}, "back": {"https://evil.example/"}} + period.Set("action", "skip") + if w := post(h, "/ui/cash", period); !strings.HasPrefix(w.Header().Get("Location"), "/ui/operations?msg=1+cash") { + t.Errorf("skip: %s", w.Header().Get("Location")) + } + if tx, _ := st.GetTx(ctx, cash); tx.Status != store.StatusIgnored { + t.Errorf("skipped: %s", tx.Status) + } + period.Set("action", "count") + post(h, "/ui/cash", period) + if tx, _ := st.GetTx(ctx, cash); tx.Status != store.StatusReview { + t.Errorf("counted again: %s", tx.Status) + } + period.Set("to", "2026-07-01") + if w := post(h, "/ui/cash", period); w.Code != http.StatusBadRequest { + t.Errorf("backwards period: %d", w.Code) + } +} diff --git a/internal/web/pages.go b/internal/web/pages.go index b674a6b..b5b50ec 100644 --- a/internal/web/pages.go +++ b/internal/web/pages.go @@ -288,6 +288,7 @@ type operationsData struct { Picker picker Back string // this page, to come back after an edit CSV string + Cash *cashBox // filtered to cash: what passed through, skipping by hand } const opsPage = 200 @@ -478,6 +479,12 @@ func (s *Server) operations(w http.ResponseWriter, r *http.Request) { } } d.Back = r.URL.RequestURI() + if d.Merchant == analytics.CashKey || d.Category == analytics.CashCategory { + if d.Cash, err = s.cashBox(r, rows, d.Current); err != nil { + s.fail(w, err) + return + } + } cq := cloneValues(q) cq.Del("limit") cq.Del("msg") diff --git a/internal/web/pages_test.go b/internal/web/pages_test.go index c8e7195..475fe15 100644 --- a/internal/web/pages_test.go +++ b/internal/web/pages_test.go @@ -54,7 +54,7 @@ func TestPagesRender(t *testing.T) { seedOps(t, st) } for _, p := range []string{"/ui", "/ui/analytics", "/ui/analytics?period=2026-08", "/ui/analytics?period=30d&nosave=1", "/ui/analytics?period=today", "/ui/analytics?period=week", "/ui/analytics?period=month", - "/ui/operations", "/ui/operations?type=all", "/ui/operations?type=transfers", "/ui/operations?cat=Cash&period=all", + "/ui/operations", "/ui/operations?type=all", "/ui/operations?type=transfers", "/ui/operations?cat=Cash&period=all", "/ui/operations?m=%23cash&period=all", "/ui/operations?from=2026-08-02&to=2026-08-04&q=magnum", "/ui/operations?m=magnum", "/ui/categories", "/ui/transfers", "/ui/transfers?name=Adam+S.", "/ui/trends", "/ui/trends?people=0&withsave=1", "/ui/wealth"} { w := do(h, "GET", p, nil, "", true) diff --git a/internal/web/templates/analytics.html b/internal/web/templates/analytics.html index d62583a..befb650 100644 --- a/internal/web/templates/analytics.html +++ b/internal/web/templates/analytics.html @@ -64,7 +64,8 @@

{{.Current.Title | title}}

- Cash {{kzt $t.Cash}} · {{$t.CashCount}} {{plural (int $t.CashCount) "withdrawal" "withdrawals"}} + Cash {{kzt $t.Cash}} · {{$t.CashCount}} {{plural (int $t.CashCount) "withdrawal" "withdrawals"}} + {{if .Passed}}Passed through {{kzt .Passed}} · {{.PassedN}} {{plural .PassedN "withdrawal" "withdrawals"}}{{end}} Uncategorized {{kzt $t.Uncategorized}}{{if $t.Uncategorized}} · review{{end}} {{if $t.TransferSpend}}Transfers as spending {{kzt $t.TransferSpend}} · who{{end}} Transfers to people {{kzt $t.TransfersOut}} diff --git a/internal/web/templates/income.html b/internal/web/templates/income.html index 97e9eae..7eb5255 100644 --- a/internal/web/templates/income.html +++ b/internal/web/templates/income.html @@ -37,6 +37,7 @@

Income

{{if $fl.People}}Went out{{else}}Spent{{end}}
{{kzt $fl.Out}}
{{if $fl.People}}spending {{compact $fl.Spend}} + to people {{compact (sub $fl.PeopleOut $fl.PeopleIn)}} · {{end}}{{kzt $fl.AvgSpend}} a month
{{if lt $fl.Net 0}}In the red{{else}}Left over{{end}}
{{if gt $fl.Net 0}}+{{end}}{{kzt $fl.Net}}
{{if $fl.Income}}{{pct $fl.Rate}} of income{{end}}{{if $fl.Deficit}} · {{$fl.Deficit}} {{plural $fl.Deficit "month" "months"}} in the red{{end}}
+{{template "passnote" (dict "Sum" $fl.Passed "N" $fl.PassedN "Period" .Current)}}

Income and spending by month

diff --git a/internal/web/templates/operations.html b/internal/web/templates/operations.html index 8066771..38d4b11 100644 --- a/internal/web/templates/operations.html +++ b/internal/web/templates/operations.html @@ -85,6 +85,35 @@

Operations

Largest
{{kzt .Stats.Largest}}
smallest {{kzt .Stats.Smallest}}
{{end}}
+{{with .Cash}} +
+

Cash that only passed through

+
+ + +
+
+

{{if not .On}}Every cash withdrawal counts as spending.{{else if .PassedN}}{{kzt .Passed}} in {{.PassedN}} {{plural .PassedN "withdrawal" "withdrawals"}} {{plural .PassedN "is" "are"}} not counted as spending: money was put on the card — from another bank, at an ATM or terminal, or by a person — and taken out as cash within a week. They are under All operations, marked “passed through”.{{else}}No cash passed through the card in this period: every withdrawal here was paid from the card’s own money.{{end}}

+ {{if or .Counted .Skipped}} +
+ {{if .Counted}} +
+ + +
+ {{end}} + {{if .Skipped}} +
+ + +
+ {{end}} +
+

Cashed out money that was not yours to spend, or put it into another account? Skip the period’s withdrawals by hand — withdrawals you gave a category stay as they are, and it can be undone here.

+ {{end}} +
+{{end}} {{if and .NoSavings (eq .Type "spend")}}

Savings categories are left out, as on the analytics page with the switch on.

{{end}} {{if .Groups}} diff --git a/internal/web/templates/parts.html b/internal/web/templates/parts.html index 98b1dfc..88e26ef 100644 --- a/internal/web/templates/parts.html +++ b/internal/web/templates/parts.html @@ -10,7 +10,7 @@ {{if $o.Kind}}{{if not (purchase $o.Kind)}}{{kind $o.Kind}}{{end}}{{else if eq $o.Source "wallet"}}Apple Wallet{{else if eq $o.Source "manual"}}manual{{end}} {{if $o.Spend}} {{range $o.Categories}}{{.}}{{else}}{{if ne $o.MerchantKey "#cash"}}no category{{end}}{{end}} - {{else if eq $o.Status "ignored"}}skipped{{end}} + {{else if eq $o.Status "ignored"}}skipped{{else if $o.PassThrough}}passed through{{end}}
@@ -114,3 +114,6 @@
{{end}}{{end}} + +{{/* passnote: cash that only passed through the card and is left out; dict Sum N Period */}} +{{define "passnote"}}{{if .Sum}}

{{template "icon" "wallet"}} {{compact .Sum}} in {{.N}} cash {{plural .N "withdrawal" "withdrawals"}} only passed through the card — money put on it from another bank, at an ATM or by people and taken out as cash within a week — and {{plural .N "is" "are"}} not counted as spending. Review

{{end}}{{end}} diff --git a/internal/web/templates/wealth.html b/internal/web/templates/wealth.html index 599b31a..71e2e13 100644 --- a/internal/web/templates/wealth.html +++ b/internal/web/templates/wealth.html @@ -52,6 +52,7 @@ {{if .Never}}—{{else}}{{.Years}}{{end}}{{end}} + {{template "passnote" (dict "Sum" $.Flow.Passed "N" $.Flow.PassedN "Period" $.Year)}}

Income and what went out are the last 12 months, as on the Income page; the capital is the net worth above. Saving more both adds to the capital and lowers the target, so years fall fast as the rate grows. A guide, not a plan: returns and prices vary.

{{end}}{{end}} diff --git a/internal/web/wealth.go b/internal/web/wealth.go index c768ded..0341a63 100644 --- a/internal/web/wealth.go +++ b/internal/web/wealth.go @@ -31,6 +31,8 @@ type wealthData struct { Cards []analytics.Series Chart template.HTML FI analytics.FI + Flow analytics.Flow // the last 12 months behind FI + Year analytics.Period // those months Today string AssetKind []string DebtKind []string @@ -104,6 +106,7 @@ func (s *Server) wealth(w http.ResponseWriter, r *http.Request) { return } flow := analytics.CashFlow(rows, f) + d.Flow, d.Year = flow, f.Period d.R = analytics.BuildWealth(series, rows, d.DebtCats, flow, today) d.FI = analytics.BuildFI(flow.Income, flow.Out, d.R.Now.Net) d.Chart = wealthSVG(d.R.Points) diff --git a/internal/web/web.go b/internal/web/web.go index d502a9b..37cdfb9 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -210,6 +210,7 @@ func (s *Server) Handler() http.Handler { ui.HandleFunc("POST /ui/transfers/ask", s.askOn("transfers")) ui.HandleFunc("POST /ui/categories/ask", s.askOn("categories")) ui.HandleFunc("POST /ui/op/{id}/category", s.opCategory) + ui.HandleFunc("POST /ui/cash", s.cashAction) ui.HandleFunc("GET /ui/categories", s.categories) ui.HandleFunc("GET /ui/income", s.income) ui.HandleFunc("GET /ui/trends", s.trends) @@ -533,6 +534,8 @@ type analyticsData struct { Newer string Heat *heatmap RecOK int + Passed int64 // cash that only passed through the card, left out of spending + PassedN int Empty bool Source string // where the data came from: ClickHouse or SQLite Fallback bool // ClickHouse is configured but unreachable — SQLite data is shown @@ -607,6 +610,7 @@ func (s *Server) analytics(w http.ResponseWriter, r *http.Request) { } } data.Heat = heat(rows, data.Current, exclude) + data.Passed, data.PassedN = analytics.PassedThrough(rows, data.Current) stmts, err := s.st.Statements(ctx, s.loc) if err != nil { s.fail(w, err) @@ -632,7 +636,7 @@ func (s *Server) ledger(ctx context.Context, data *analyticsData) ([]store.Ledge rows, err := analysis.LedgerFromClickHouse(ctx, s.ch, s.loc) if err == nil { data.Source = "ClickHouse" - return rows, nil + return analysis.MarkPassThrough(ctx, s.st, rows) } s.log.Warn("web: clickhouse unavailable, using sqlite", "err", err) data.Fallback = true @@ -944,6 +948,9 @@ func (s *Server) rows(ctx context.Context) ([]store.LedgerRow, error) { if err != nil { return nil, err } + if rows, err = analysis.MarkPassThrough(ctx, s.st, rows); err != nil { + return nil, err + } s.cache.version, s.cache.rows = v, rows return rows, nil }