Skip to content

Improve budget median: use last 6 months instead of all-time data#1056

Open
ChakibMoMi wants to merge 2 commits intowe-promise:mainfrom
ChakibMoMi:feature/bugfix-budget-median
Open

Improve budget median: use last 6 months instead of all-time data#1056
ChakibMoMi wants to merge 2 commits intowe-promise:mainfrom
ChakibMoMi:feature/bugfix-budget-median

Conversation

@ChakibMoMi
Copy link
Copy Markdown

@ChakibMoMi ChakibMoMi commented Feb 22, 2026

Budget statistics now use a 6-month lookback window for median/average calculations, producing more realistic budget suggestions based on recent spending patterns rather than all-time historical data.

Changes:

  • Add STATS_LOOKBACK_MONTHS (6) to FamilyStats and CategoryStats
  • Filter stats queries to only include completed months within window
  • Filter to active/draft accounts only
  • Fix budget_category daily spending to use budget period dates instead of calendar month (supports custom month start)
  • Update tests to create transactions within the lookback window

Summary by CodeRabbit

  • Bug Fixes

    • Daily spending suggestion now respects a budget's actual date range and uses the budget end date for remaining-days; cache keys updated so stats refresh when the lookback window changes.
  • New Features

    • Income statement adds a 6-month lookback window and restricts results to visible account statuses.
  • Tests

    • Tests use dated transactions anchored to a past month for deterministic aggregates and add an edge case excluding data outside the 6-month lookback.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c9d5e2e8-7031-4679-996b-8477a40c19f6

📥 Commits

Reviewing files that changed from the base of the PR and between 5f74a91 and 5caf91a.

📒 Files selected for processing (5)
  • app/models/budget_category.rb
  • app/models/income_statement/category_stats.rb
  • app/models/income_statement/family_stats.rb
  • app/models/income_statement/stats_lookback.rb
  • test/models/income_statement_test.rb
🚧 Files skipped from review as they are similar to previous changes (4)
  • app/models/budget_category.rb
  • app/models/income_statement/stats_lookback.rb
  • app/models/income_statement/category_stats.rb
  • test/models/income_statement_test.rb

📝 Walkthrough

Walkthrough

Adds a 6-month lookback window to income-statement stats (wired into queries and cache keys) and updates budget daily-spending logic to use explicit budget.start_date..budget.end_date and budget.end_date for days-remaining.

Changes

Cohort / File(s) Summary
Budget period boundary
app/models/budget_category.rb
Replaced calendar-month checks with budget.start_date..budget.end_date range check in suggested_daily_spending; changed days_remaining to use budget.end_date.
Income statement lookback (module & core)
app/models/income_statement/stats_lookback.rb, app/models/income_statement.rb
Added IncomeStatement::StatsLookback (STATS_LOOKBACK_MONTHS, lookback date helpers, visible-account-status extraction); included it in IncomeStatement; added stats_lookback_key into cache keys.
Income statement lookback (consumers)
app/models/income_statement/family_stats.rb, app/models/income_statement/category_stats.rb
Included IncomeStatement::StatsLookback; added lookback_start/lookback_end/visible_statuses to sql_params; tightened query_sql to filter AE.date between lookback dates and to restrict accounts to visible statuses.
Tests
test/models/income_statement_test.rb
Replaced synthetic same-month transactions with explicit dated transactions within a 6-month lookback window; added tests for excluding transactions outside lookback and updated median/total expectations.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant IncomeStatement as IncomeStatement Service
  participant Cache
  participant DB as Database

  Client->>IncomeStatement: request family/category stats
  IncomeStatement->>Cache: build cache key (includes stats_lookback_key)
  Cache-->>IncomeStatement: miss / hit
  alt cache miss
    IncomeStatement->>IncomeStatement: compute lookback_start/end, visible_statuses
    IncomeStatement->>DB: query transactions with lookback date bounds and status filter
    DB-->>IncomeStatement: rows
    IncomeStatement->>Cache: store serialized result with key
    IncomeStatement-->>Client: return stats
  else cache hit
    Cache-->>IncomeStatement: cached result
    IncomeStatement-->>Client: return stats
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • jjmata
  • sokie

Poem

🐇 I hopped six months back in a blink,
Counted the dates and gave them a wink,
Budgets now end where they should end,
Queries look back, old rows suspend,
Ledger carrots crunch — stats on the mend.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and clearly summarizes the main change: implementing a 6-month lookback window for budget median calculations instead of using all-time data.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dosubot
Copy link
Copy Markdown

dosubot Bot commented Feb 22, 2026

Related Documentation

Checked 1 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49a0bcdc2d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +24 to +25
def lookback_start_date
STATS_LOOKBACK_MONTHS.months.ago.beginning_of_month.to_date
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add lookback period to income statement cache key

These lookback dates are derived from Date.current, so the stats query result changes automatically when the month rolls over, but IncomeStatement#family_stats/#category_stats cache entries are keyed only by family, interval, and entries_cache_version (no date component). That means a cache populated on the last day of a month can continue serving the previous 6-month window on the first day of the next month until any entry is updated, yielding stale medians/averages for budgeting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
app/models/income_statement/family_stats.rb (1)

24-30: lookback_start_date / lookback_end_date are duplicated verbatim in CategoryStats

The two private helpers at lines 24–30 are copy-pasted identically into IncomeStatement::CategoryStats (lines 23–29). Per the project's coding guidelines, shared logic should live in a Rails concern or PORO rather than being duplicated across classes.

♻️ Suggested extraction into a shared module
# app/models/income_statement/stats_lookback.rb  (new file)
module IncomeStatement::StatsLookback
  STATS_LOOKBACK_MONTHS = 6

  private

  def lookback_start_date
    STATS_LOOKBACK_MONTHS.months.ago.beginning_of_month.to_date
  end

  def lookback_end_date
    1.month.ago.end_of_month.to_date
  end
end

Then in both FamilyStats and CategoryStats:

+include IncomeStatement::StatsLookback
-STATS_LOOKBACK_MONTHS = 6          # FamilyStats
-STATS_LOOKBACK_MONTHS = IncomeStatement::FamilyStats::STATS_LOOKBACK_MONTHS  # CategoryStats
-
-def lookback_start_date
-  STATS_LOOKBACK_MONTHS.months.ago.beginning_of_month.to_date
-end
-
-def lookback_end_date
-  1.month.ago.end_of_month.to_date
-end

As per coding guidelines: "Use Rails concerns and POROs for organizing business logic instead of services folder."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/income_statement/family_stats.rb` around lines 24 - 30, Extract
the duplicated lookback helpers into a shared module (e.g.
IncomeStatement::StatsLookback) that defines STATS_LOOKBACK_MONTHS and the
private methods lookback_start_date and lookback_end_date, then include that
concern into both IncomeStatement::FamilyStats and
IncomeStatement::CategoryStats and remove the duplicate method definitions from
those classes so they use the shared implementations.
app/models/income_statement/category_stats.rb (1)

2-2: STATS_LOOKBACK_MONTHS aliasing creates a cross-class load-order dependency

Aliasing IncomeStatement::FamilyStats::STATS_LOOKBACK_MONTHS at the class level means CategoryStats can only be loaded after FamilyStats. While Zeitwerk normally handles this, the shared-concern extraction suggested for family_stats.rb would eliminate the coupling entirely — both classes would include IncomeStatement::StatsLookback and get STATS_LOOKBACK_MONTHS from a single authoritative source.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/income_statement/category_stats.rb` at line 2, Replace the
class-level aliasing of STATS_LOOKBACK_MONTHS by extracting the constant into a
shared module (e.g., define module IncomeStatement::StatsLookback with
STATS_LOOKBACK_MONTHS) and have both IncomeStatement::FamilyStats and
CategoryStats include IncomeStatement::StatsLookback; remove the direct
reference/alias STATS_LOOKBACK_MONTHS =
IncomeStatement::FamilyStats::STATS_LOOKBACK_MONTHS from CategoryStats and stop
relying on IncomeStatement::FamilyStats for the constant so load-order coupling
is eliminated.
app/models/budget_category.rb (1)

160-166: Redundant guard on line 166 after the new inclusive range check

Since line 163 already guarantees Date.current <= budget.end_date, days_remaining will always be ≥ 1 ((end_date - current).to_i + 1 ≥ 1). The return nil unless days_remaining > 0 on line 166 is now dead code.

🧹 Proposed clean-up
-    days_remaining = (budget.end_date - Date.current).to_i + 1
-    return nil unless days_remaining > 0
-
-    {
+    days_remaining = (budget.end_date - Date.current).to_i + 1
+    {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/budget_category.rb` around lines 160 - 166, The guard `return nil
unless days_remaining > 0` is redundant because you already ensure Date.current
is between budget.start_date and budget.end_date (inclusive) earlier; remove the
redundant check and keep the days_remaining calculation (`days_remaining =
(budget.end_date - Date.current).to_i + 1`) as-is so later code can use it
without an unreachable early return. Ensure this change affects the method that
references available_to_spend, budget.start_date, budget.end_date, and
days_remaining.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/models/income_statement/category_stats.rb`:
- Around line 23-29: The lookback_start_date and lookback_end_date methods in
IncomeStatement::CategoryStats are duplicated from IncomeStatement::FamilyStats;
extract these two helpers into a shared module (e.g.,
IncomeStatement::LookbackHelpers or a Concern) and include it into both
IncomeStatement::CategoryStats and IncomeStatement::FamilyStats so both classes
call the same methods (lookback_start_date and lookback_end_date) from the
shared module instead of defining them twice.
- Around line 83-85: The SQL in CategoryStats is using hardcoded account status
strings ("draft","active") like FamilyStats; replace that literal list with the
shared source used in FamilyStats (e.g., the Account model constant/scope
referenced there) so both CategoryStats and FamilyStats derive allowed statuses
from the same symbol (for example Account::ALLOWED_STATUSES or
Account.active_and_draft scope) and update the query in CategoryStats (the
a.status IN (...) clause) to use that constant/scope or bound parameter instead
of hardcoded strings.

---

Nitpick comments:
In `@app/models/budget_category.rb`:
- Around line 160-166: The guard `return nil unless days_remaining > 0` is
redundant because you already ensure Date.current is between budget.start_date
and budget.end_date (inclusive) earlier; remove the redundant check and keep the
days_remaining calculation (`days_remaining = (budget.end_date -
Date.current).to_i + 1`) as-is so later code can use it without an unreachable
early return. Ensure this change affects the method that references
available_to_spend, budget.start_date, budget.end_date, and days_remaining.

In `@app/models/income_statement/category_stats.rb`:
- Line 2: Replace the class-level aliasing of STATS_LOOKBACK_MONTHS by
extracting the constant into a shared module (e.g., define module
IncomeStatement::StatsLookback with STATS_LOOKBACK_MONTHS) and have both
IncomeStatement::FamilyStats and CategoryStats include
IncomeStatement::StatsLookback; remove the direct reference/alias
STATS_LOOKBACK_MONTHS = IncomeStatement::FamilyStats::STATS_LOOKBACK_MONTHS from
CategoryStats and stop relying on IncomeStatement::FamilyStats for the constant
so load-order coupling is eliminated.

In `@app/models/income_statement/family_stats.rb`:
- Around line 24-30: Extract the duplicated lookback helpers into a shared
module (e.g. IncomeStatement::StatsLookback) that defines STATS_LOOKBACK_MONTHS
and the private methods lookback_start_date and lookback_end_date, then include
that concern into both IncomeStatement::FamilyStats and
IncomeStatement::CategoryStats and remove the duplicate method definitions from
those classes so they use the shared implementations.

@ChakibMoMi ChakibMoMi force-pushed the feature/bugfix-budget-median branch from 49a0bcd to 6b4858c Compare February 22, 2026 22:26
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
app/models/income_statement.rb (1)

133-135: stats_lookback_key duplicates the formula from StatsLookback#lookback_start_date.

If the lookback start formula in StatsLookback is ever updated, stats_lookback_key will silently diverge, producing stale cache keys. Including the module eliminates the duplication:

♻️ Proposed refactor
 class IncomeStatement
   include Monetizable
+  include IncomeStatement::StatsLookback
 ...
     def stats_lookback_key
-      StatsLookback::STATS_LOOKBACK_MONTHS.months.ago.beginning_of_month.to_date.to_s
+      lookback_start_date.to_s
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/income_statement.rb` around lines 133 - 135, Replace the
duplicated date formula in the stats_lookback_key method by delegating to the
canonical implementation in StatsLookback (e.g. call
StatsLookback#lookback_start_date or include the StatsLookback module) so the
cache key is derived from that single source of truth; ensure you convert the
returned value to the same string format (to_date.to_s) so stats_lookback_key
returns an identical string as before.
app/models/income_statement/stats_lookback.rb (1)

10-12: Consider documenting why lookback_end_date doesn't reference STATS_LOOKBACK_MONTHS.

lookback_end_date always returns 1.month.ago.end_of_month regardless of STATS_LOOKBACK_MONTHS. This is intentional — the window always ends at the last completed month and STATS_LOOKBACK_MONTHS only controls how far back the start reaches. A brief comment would prevent a future maintainer from "fixing" this by tying the end to the constant.

📝 Suggested inline documentation
     def lookback_end_date
+      # Always end at the last completed month, regardless of how far back the
+      # window extends — ensures the current in-progress month is never included.
       1.month.ago.end_of_month.to_date
     end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/income_statement/stats_lookback.rb` around lines 10 - 12, Add a
short inline comment above the lookback_end_date method clarifying that the end
of the window is intentionally fixed to the last completed month
(1.month.ago.end_of_month) and should not be tied to STATS_LOOKBACK_MONTHS
because that constant only controls how far back the start date goes; reference
the method name lookback_end_date and the constant STATS_LOOKBACK_MONTHS in the
comment so future maintainers understand the intent.
test/models/income_statement_test.rb (1)

231-260: Week-median comments assume base_date is a Monday; consider asserting exact values.

The comments claim week_median = 800 (from groups [300, 1000, 800]), but PostgreSQL date_trunc('week', ...) truncates to Monday. When 2.months.ago.beginning_of_month falls on a Sunday, base_date and base_date + 1.day land in different ISO weeks, changing the groupings and the resulting median. The refute_equal assertion still holds in all cases (the monthly total 2100 can never equal a per-week median), but the specific numeric comments become misleading. Asserting both concrete values would make the test self-documenting and catch regressions:

♻️ Stronger assertions (values valid when base_date is a Monday)
-    refute_equal month_median, week_median, "Different intervals should return different statistical results when data spans multiple time periods"
-
-    # Both should still be numeric
-    assert month_median.is_a?(Numeric)
-    assert week_median.is_a?(Numeric)
-    assert month_median > 0
-    assert week_median > 0
+    assert_equal 2100.0, month_median
+    assert_equal 800.0,  week_median

If the day-of-week variance is a concern, anchor base_date to a known Monday:

-    base_date = 2.months.ago.beginning_of_month.to_date
+    # Pin to the most recent Monday in a past completed month so week groupings are deterministic
+    raw = 2.months.ago.beginning_of_month.to_date
+    base_date = raw + ((1 - raw.wday) % 7)  # advance to the first Monday of that month
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/models/income_statement_test.rb` around lines 231 - 260, The test's week
grouping is flaky because base_date can fall on a Sunday and PostgreSQL
date_trunc('week') groups by Monday; update the test so it is deterministic by
anchoring base_date to a known weekday (e.g., force base_date to the first
Monday of the month) or replace the loose refute_equal with concrete assertions
for IncomeStatement#median_expense for both intervals (month and week) using the
expected numeric medians; locate the test setup using base_date and the calls to
create_transaction and IncomeStatement#median_expense to implement the change.
app/models/income_statement/family_stats.rb (1)

72-74: Use the existing Account.visible scope instead of hardcoding status values.

The Account model already defines a visible scope with identical status logic: where(status: [ "draft", "active" ]). For consistency and maintainability, reference this scope or extract the status values to a constant (similar to Transaction::BUDGET_EXCLUDED_KINDS) rather than duplicating hardcoded strings.

Note: Account uses AASM state machine, not a Rails enum. The default status is :active, so test accounts created without an explicit status won't be silently dropped by this filter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/income_statement/family_stats.rb` around lines 72 - 74, Replace
the hardcoded status filter "a.status IN ('draft', 'active')" with the existing
Account.visible scope (or use a shared constant for statuses) so we don't
duplicate logic; locate the SQL fragment in family_stats.rb where the query uses
the alias "a" and either switch this raw SQL to use Account.visible (or
interpolate Account.visible.pluck(:status) / a constant) or refactor the query
to join/filter via Account.visible to ensure consistent status logic with the
Account model.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/models/income_statement/category_stats.rb`:
- Line 2: The SQL in IncomeStatement::CategoryStats currently hardcodes the
status strings 'draft' and 'active' (same issue as in FamilyStats); update the
SQL to reference the canonical status values instead of literals by using the
model/enum constants used in family_stats (e.g., the Category model's status
enum or a STATUS_DRAFT/STATUS_ACTIVE constants) and pass them as bound
parameters (or via Arel/ActiveRecord helpers) in the query generation method
inside IncomeStatement::CategoryStats (the method that builds the SQL for
lookback stats included via IncomeStatement::StatsLookback) so the code uses the
shared constants and avoids hardcoded strings.

---

Nitpick comments:
In `@app/models/income_statement.rb`:
- Around line 133-135: Replace the duplicated date formula in the
stats_lookback_key method by delegating to the canonical implementation in
StatsLookback (e.g. call StatsLookback#lookback_start_date or include the
StatsLookback module) so the cache key is derived from that single source of
truth; ensure you convert the returned value to the same string format
(to_date.to_s) so stats_lookback_key returns an identical string as before.

In `@app/models/income_statement/family_stats.rb`:
- Around line 72-74: Replace the hardcoded status filter "a.status IN ('draft',
'active')" with the existing Account.visible scope (or use a shared constant for
statuses) so we don't duplicate logic; locate the SQL fragment in
family_stats.rb where the query uses the alias "a" and either switch this raw
SQL to use Account.visible (or interpolate Account.visible.pluck(:status) / a
constant) or refactor the query to join/filter via Account.visible to ensure
consistent status logic with the Account model.

In `@app/models/income_statement/stats_lookback.rb`:
- Around line 10-12: Add a short inline comment above the lookback_end_date
method clarifying that the end of the window is intentionally fixed to the last
completed month (1.month.ago.end_of_month) and should not be tied to
STATS_LOOKBACK_MONTHS because that constant only controls how far back the start
date goes; reference the method name lookback_end_date and the constant
STATS_LOOKBACK_MONTHS in the comment so future maintainers understand the
intent.

In `@test/models/income_statement_test.rb`:
- Around line 231-260: The test's week grouping is flaky because base_date can
fall on a Sunday and PostgreSQL date_trunc('week') groups by Monday; update the
test so it is deterministic by anchoring base_date to a known weekday (e.g.,
force base_date to the first Monday of the month) or replace the loose
refute_equal with concrete assertions for IncomeStatement#median_expense for
both intervals (month and week) using the expected numeric medians; locate the
test setup using base_date and the calls to create_transaction and
IncomeStatement#median_expense to implement the change.

Budget statistics now use a 6-month lookback window for median/average
calculations, producing more realistic budget suggestions based on recent
spending patterns rather than all-time historical data.

Changes:
- Extract StatsLookback concern to DRY up lookback logic between FamilyStats
  and CategoryStats
- Add lookback date to cache key so stats invalidate on month rollover
- Filter stats queries to completed months within 6-month window
- Filter to active/draft accounts only
- Use Date.current.between? for budget period check
- Fix budget_category daily spending to use budget period dates
- Update tests to create transactions within the lookback window
@ChakibMoMi ChakibMoMi force-pushed the feature/bugfix-budget-median branch from 6b4858c to 5f74a91 Compare February 22, 2026 22:56
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
app/models/income_statement/stats_lookback.rb (1)

4-5: VISIBLE_ACCOUNT_STATUSES duplicates Account.visible scope — silent drift risk

The constant at line 4-5 exactly mirrors Account.visible (line 21 of app/models/account.rb), which defines status: ["draft", "active"]. If Account.visible gains a new status (e.g., "syncing"), stats queries will silently exclude those accounts with no error or test failure.

Safer approach: expose Account::VISIBLE_STATUSES on the Account model and derive from it:

♻️ Suggested refactor: single source of truth for account statuses
-  # Matches Account.visible scope: where(status: ["draft", "active"])
-  VISIBLE_ACCOUNT_STATUSES = %w[draft active].freeze
-
-  ...
-
-    def visible_account_statuses_sql
-      `@visible_account_statuses_sql` ||= VISIBLE_ACCOUNT_STATUSES.map { |s| "'#{s}'" }.join(", ")
-    end
+    def visible_account_statuses_sql
+      `@visible_account_statuses_sql` ||=
+        Account::VISIBLE_STATUSES.map { |s| ActiveRecord::Base.connection.quote(s) }.join(", ")
+    end

This requires adding VISIBLE_STATUSES = %w[draft active].freeze to the Account model — eliminates the duplication and uses proper SQL quoting.

Also applies to: 19-21

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/income_statement/stats_lookback.rb` around lines 4 - 5, The
VISIBLE_ACCOUNT_STATUSES constant duplicates Account.visible and risks drifting;
remove or replace VISIBLE_ACCOUNT_STATUSES in stats_lookback.rb and derive
statuses from Account by referencing a single source like
Account::VISIBLE_STATUSES (and update Account model to define VISIBLE_STATUSES =
%w[draft active].freeze if not present) so queries use Account::VISIBLE_STATUSES
instead of a hardcoded array; update any other occurrences (e.g., the similar
block at lines 19-21) to use Account::VISIBLE_STATUSES to ensure a single source
of truth.
test/models/income_statement_test.rb (1)

67-78: The status filter concern is not an issue—Account.status defaults to "active" (confirmed in both the AASM state machine and database schema), which matches the visible scope filter of ['draft', 'active']. The test setup will work correctly.

However, explicitly setting status: :active when creating @checking_account in the test setup remains a good practice for clarity and defensive programming against future default changes:

-    `@checking_account` = `@family.accounts.create`! name: "Checking", currency: `@family.currency`, balance: 5000, accountable: Depository.new
+    `@checking_account` = `@family.accounts.create`! name: "Checking", currency: `@family.currency`, balance: 5000, accountable: Depository.new, status: :active
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/models/income_statement_test.rb` around lines 67 - 78, The test setup
should explicitly set the checking account's status to active to make the intent
clear and protect against future default changes; update the setup where
`@checking_account` is created (the Account instantiation used by the test) to
include status: :active so it clearly satisfies the visible scope and aligns
with assertions in IncomeStatement#median_expense (used in the test), ensuring
the created `@checking_account` is unambiguously active during the test run.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/models/income_statement/stats_lookback.rb`:
- Around line 4-5: The VISIBLE_ACCOUNT_STATUSES constant duplicates
Account.visible and risks drifting; remove or replace VISIBLE_ACCOUNT_STATUSES
in stats_lookback.rb and derive statuses from Account by referencing a single
source like Account::VISIBLE_STATUSES (and update Account model to define
VISIBLE_STATUSES = %w[draft active].freeze if not present) so queries use
Account::VISIBLE_STATUSES instead of a hardcoded array; update any other
occurrences (e.g., the similar block at lines 19-21) to use
Account::VISIBLE_STATUSES to ensure a single source of truth.

In `@test/models/income_statement_test.rb`:
- Around line 67-78: The test setup should explicitly set the checking account's
status to active to make the intent clear and protect against future default
changes; update the setup where `@checking_account` is created (the Account
instantiation used by the test) to include status: :active so it clearly
satisfies the visible scope and aligns with assertions in
IncomeStatement#median_expense (used in the test), ensuring the created
`@checking_account` is unambiguously active during the test run.

@jjmata
Copy link
Copy Markdown
Collaborator

jjmata commented Mar 7, 2026

PR Review: "Improve budget median: use last 6 months instead of all-time data" (#1056)

Summary

This PR introduces a 6-month lookback window for budget statistics (median/average expense and income calculations), replacing the previous all-time approach. It also improves the suggested_daily_spending logic in BudgetCategory to use explicit budget period boundaries.

What's Good

  1. Sound business rationale - Using recent data (last 6 completed months) for budget suggestions is clearly better than all-time data, which would increasingly skew as accounts age.

  2. Clean extraction of StatsLookback concern - Centralizing the lookback logic into a shared module avoids duplication between FamilyStats and CategoryStats.

  3. Cache invalidation handled properly - Adding stats_lookback_key (based on lookback_start_date) to cache keys ensures automatic cache busting on month boundaries.

  4. budget_category.rb improvement - Using Date.current.between?(budget.start_date, budget.end_date) is more robust than the old month/year comparison, and properly supports non-calendar-month budget periods.

  5. Test updates are thorough - Transactions are anchored to past completed months, and week-interval tests use deterministic Monday-based dates to avoid flaky grouping.

Issues to Address

1. SQL Injection Risk in visible_account_statuses_sql (Medium)

def visible_account_statuses_sql
  @visible_account_statuses_sql ||= VISIBLE_ACCOUNT_STATUSES.map { |s| "'#{s}'" }.join(", ")
end

While the values come from a frozen constant (so there's no actual exploit path today), manually interpolating strings into SQL with '#{s}' is a bad pattern. If someone later makes VISIBLE_ACCOUNT_STATUSES dynamic or user-influenced, this becomes a real injection vector. The same pattern is used for budget_excluded_kinds_sql in the existing code, but it's better not to propagate it.

Suggestion: Use parameterized queries instead. Pass the statuses as a bind parameter array, or at minimum use ActiveRecord::Base.connection.quote().

2. Hardcoded Account Statuses (Low-Medium)

VISIBLE_ACCOUNT_STATUSES = %w[draft active].freeze duplicates the logic from Account.visible scope (where(status: ["draft", "active"])). If Account statuses change, these will silently diverge.

Suggestion: Reference the Account model directly. For example:

# In StatsLookback or at usage site:
Account.visible.where_clause  # or extract the statuses from Account

Or at minimum, add a test that asserts StatsLookback::VISIBLE_ACCOUNT_STATUSES matches what Account.visible uses.

3. Missing Guard for days_remaining <= 0 in budget_category.rb (Medium)

The old code had return nil unless days_remaining > 0. The new code removes this guard:

days_remaining = (budget.end_date - Date.current).to_i + 1

{
  amount: Money.new((available_to_spend / days_remaining), budget.family.currency),
  days_remaining: days_remaining
}

While Date.current.between?(budget.start_date, budget.end_date) should guarantee days_remaining >= 1, there's a subtle edge case: if budget.end_date == Date.current, then days_remaining = 1, which is fine. But if there's any clock/timezone mismatch where Date.current evaluates to the day after budget.end_date between the between? check and the subtraction, you'd get days_remaining = 0 and a division-by-zero. This is extremely unlikely but the guard was essentially free.

Suggestion: Keep the return nil unless days_remaining > 0 guard as a safety net. It costs nothing and prevents a potential ZeroDivisionError.

4. lookback_end_date Excludes Current Month Entirely (Design Question)

The lookback window is 6.months.ago.beginning_of_month to 1.month.ago.end_of_month, intentionally excluding the current (incomplete) month. This is a reasonable choice for statistical accuracy, but it means a new user with only 1 month of data (the current month) would get zero stats. Worth confirming this is the desired UX behavior - possibly the UI already handles the "no data" case gracefully.

5. Instance Variable Memoization in a Module (Minor)

def visible_account_statuses_sql
  @visible_account_statuses_sql ||= VISIBLE_ACCOUNT_STATUSES.map { |s| "'#{s}'" }.join(", ")
end

This memoizes on the including instance. Since FamilyStats and CategoryStats are short-lived objects (created per-call), this is harmless but also unnecessary - the constant is already frozen and the string join is trivial. Could just be a class-level constant or a simple method without memoization.

Test Coverage

The tests cover median expense, median income, average expense, category-specific stats, and week vs month intervals. The refactoring to use past_month = 2.months.ago.beginning_of_month.to_date ensures transactions land within the lookback window.

One gap: there's no test verifying that transactions outside the 6-month window are excluded. Adding a test that creates an old transaction (e.g., 8 months ago) and confirms it doesn't affect the median/average would strengthen confidence in the filtering.

Verdict

The core change is well-motivated and cleanly implemented. I'd recommend addressing the division-by-zero guard removal (#3) and adding a test for exclusion of old data before merging. The SQL interpolation pattern (#1) and hardcoded statuses (#2) are worth discussing but could be follow-up items.

@jjmata jjmata self-requested a review March 7, 2026 00:46
Copy link
Copy Markdown
Collaborator

@jjmata jjmata left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see my last comment for a Claude Code review of this PR.

…estore guard

- Replace manual SQL string interpolation in visible_account_statuses_sql
  with parameterized bind params (:visible_statuses) to prevent SQL injection
- Derive visible statuses from Account.visible scope instead of hardcoded
  VISIBLE_ACCOUNT_STATUSES constant to avoid divergence
- Restore days_remaining > 0 guard in budget_category#suggested_daily_spending
  as safety net against potential ZeroDivisionError
- Remove unnecessary instance variable memoization in StatsLookback
- Add test verifying transactions outside 6-month lookback window are excluded
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants