#3599·sure

Bug: merging or deleting a category leaves dashboard / income-statement totals stale

Author: joshiarnavCreated Sep 16, 2026Updated Sep 16, 2026

How are you using Sure?

  • I am a self-hosted user (local only)

Self hoster checklist

  • Self hosted app commit SHA: main @ 44bbfb2
  • I have confirmed that my app's commit is the latest version of Sure
  • Where are you hosting? Other: LXC (Proxmox)

Bug description

Income-statement / dashboard aggregates (IncomeStatement#family_stats, #category_stats, #totals_query, and Transaction::Search#totals) are cached under Family#entries_cache_version, which is derived from entries.count and entries.maximum(:updated_at). That works for every normal write because Entryable declares has_one :entry, touch: true, so saving a Transaction bumps its Entry.

Two paths write transactions.category_id with update_all, which skips callbacks and therefore never touches the entries:

  • Category::Merger#merge_sources!family.transactions.where(category_id: source.id).update_all(category_id: target_category.id) (app/models/category/merger.rb:31)
  • Category#replace_and_destroy!transactions.update_all category_id: replacement&.id (app/models/category.rb:349), used by "Delete category and move transactions to …"

After either operation the cache key is unchanged, so the dashboard keeps serving the pre-merge breakdown — including a slice for a category that no longer exists — until some unrelated Entry is created/updated/deleted for that family.

To Reproduce

  1. Load the dashboard so the current period's category breakdown is cached (e.g. categories "Food" and "Groceries" both visible).
  2. Settings → Categories → merge "Groceries" into "Food" (or delete "Groceries" and move its transactions to "Food").
  3. Transactions list shows the moved rows under "Food".
  4. Reload the dashboard: the breakdown still shows "Groceries" with its old total and "Food" with its old total.
  5. Edit any unrelated transaction's name → reload → now correct.

Expected behavior

Category merge and delete-with-replacement invalidate the cached aggregates like any other categorization change.

Proposed fix

Touch the affected transactions' entries after the bulk write in both paths (Entry.where(entryable_type: "Transaction", entryable_id: ids).touch_all inside the same DB transaction), or route the writes through a shared helper on Transaction that does so. Regression tests: entries_cache_version changes after merge and after replace-and-destroy.