From 3ee577342f444fec635eb286ae1bf388ae645f55 Mon Sep 17 00:00:00 2001 From: Niet <37306438+armysyn@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:38:23 +0500 Subject: [PATCH] Leave cash that only passed through the card out of spending Every cash withdrawal counted as spending. Someone who puts tens of millions a month on the card from another bank and takes it out as cash had all of it counted as spent: a year of spending in the hundreds of millions and a financial independence target in the billions. Now money put on the card that is not income (own money from another bank, an ATM or terminal deposit, money from a person) and taken out as cash within a week only passes through: the withdrawal is not spending, and the part of a top-up from a person that left as cash is not money from people either. It is decided in Go over the ledger rows (analytics.MarkPassThrough) for SQLite, ClickHouse and the insights alike. A withdrawal counts only when such money covers it whole; one the person gave a category stays spending. Salary and income sources never pay for it. The rule is on by default and can be turned off per account on the operations page filtered to cash, which also skips a period's cash withdrawals by hand and undoes it. The analytics, income and net worth pages say how much was left out, with a link to review it; such withdrawals carry a "passed through" tag. Co-Authored-By: Claude Opus 5.5 --- internal/analysis/ledger.go | 20 +++++ internal/analysis/runner.go | 8 ++ internal/analytics/analytics.go | 9 +- internal/analytics/explore.go | 3 +- internal/analytics/income.go | 5 +- internal/analytics/passthrough.go | 118 +++++++++++++++++++++++++ internal/analytics/passthrough_test.go | 85 ++++++++++++++++++ internal/store/edit.go | 52 +++++++++++ internal/store/edit_test.go | 46 ++++++++++ internal/store/ledger.go | 3 + internal/web/cash.go | 81 +++++++++++++++++ internal/web/cash_test.go | 74 ++++++++++++++++ internal/web/pages.go | 7 ++ internal/web/pages_test.go | 2 +- internal/web/templates/analytics.html | 3 +- internal/web/templates/income.html | 1 + internal/web/templates/operations.html | 29 ++++++ internal/web/templates/parts.html | 5 +- internal/web/templates/wealth.html | 1 + internal/web/wealth.go | 3 + internal/web/web.go | 9 +- 21 files changed, 554 insertions(+), 10 deletions(-) create mode 100644 internal/analytics/passthrough.go create mode 100644 internal/analytics/passthrough_test.go create mode 100644 internal/web/cash.go create mode 100644 internal/web/cash_test.go 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 }