Skip to content

fix(balances): materialize entries dated before the opening anchor#2434

Open
vlnd0 wants to merge 1 commit into
we-promise:mainfrom
vlnd0:fix/materialize-balances-before-opening-anchor
Open

fix(balances): materialize entries dated before the opening anchor#2434
vlnd0 wants to merge 1 commit into
we-promise:mainfrom
vlnd0:fix/materialize-balances-before-opening-anchor

Conversation

@vlnd0

@vlnd0 vlnd0 commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Problem

Manual Valuation/reconciliation entries dated before an account's opening_anchor are stored but never materialized into Balance rows, so the net-worth chart is empty for that period and a normal sync never backfills it.

Root cause: both balance calculators use account.opening_anchor_date as the hard lower bound of iteration:

  • Balance::ForwardCalculator#calc_start_dateaccount.opening_anchor_date
  • Balance::ReverseCalculatorcurrent_anchor_date.downto(account.opening_anchor_date)

and Account::OpeningBalanceManager#opening_date returns the opening-anchor valuation's date whenever one exists. The system intends the opening anchor to sit at/before the oldest entry (set_opening_balance even validates this), but the reconciliation path (create_reconciliation) does not move the anchor back when a valuation is created with an earlier date. The invariant breaks, and every entry before the anchor is silently clipped out of the series.

How to reproduce

  1. Create an account (gets an opening_anchor at, say, today).
  2. Backfill a reconciliation Valuation dated a year ago.
  3. Sync. The valuation exists, but account.balances has no row for that date → net-worth chart flat/empty before the anchor.

Fix

Bound the calculation window on min(opening_anchor_date, oldest_entry_date) instead of the anchor date alone (new shared Balance::BaseCalculator#calculation_start_date).

  • Forward: when the window starts before the anchor, seed the running balance from zero. The earliest reconciliation and the opening anchor each reset the absolute balance on their own dates via the existing valuation-override path, so only the pre-anchor opening day's *_adjustments field is affected — later totals are correct.
  • Reverse: extend the downto bound; use_opening_anchor_for_date? still keys off the anchor's real date, so the anchor's own treatment is unchanged and pre-anchor reconciliation waypoints reset normally.

No data migration and no anchor mutation — purely a calculation-window fix, so it heals existing accounts on the next sync.

Tests

  • forward_calculator_test.rb: "materializes entries dated before the opening anchor" — reconciliation before the anchor is materialized; window starts at the earliest entry; balances correct across the anchor.
  • reverse_calculator_test.rb: "materializes reconciliation waypoints dated before the opening anchor".

Summary by CodeRabbit

  • Bug Fixes
    • Improved balance materialization to correctly handle ledger entries and reconciliation waypoints that occur before an account’s opening anchor.
    • Updated forward, reverse, and incremental sync behavior to use the calculator’s effective lower-bound start date, ensuring correct initialization, resets, carry-forward, and preventing incremental purges from deleting pre-anchor balances.
  • Tests
    • Added regression coverage for forward calculation seeding before the opening anchor.
    • Added regression coverage for reverse calculation materializing pre-anchor reconciliation waypoints.
    • Added regression coverage ensuring incremental sync preserves pre-anchor balances.

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e12ad86-8c53-4935-9641-3ff13a05780f

📥 Commits

Reviewing files that changed from the base of the PR and between 703e7aa and 89584b8.

📒 Files selected for processing (7)
  • app/models/balance/base_calculator.rb
  • app/models/balance/forward_calculator.rb
  • app/models/balance/materializer.rb
  • app/models/balance/reverse_calculator.rb
  • test/models/balance/forward_calculator_test.rb
  • test/models/balance/materializer_test.rb
  • test/models/balance/reverse_calculator_test.rb
🚧 Files skipped from review as they are similar to previous changes (6)
  • app/models/balance/reverse_calculator.rb
  • test/models/balance/reverse_calculator_test.rb
  • app/models/balance/base_calculator.rb
  • app/models/balance/forward_calculator.rb
  • test/models/balance/forward_calculator_test.rb
  • test/models/balance/materializer_test.rb

📝 Walkthrough

Walkthrough

Adds a calculation_start_date helper to Balance::BaseCalculator that returns the minimum of opening_anchor_date and the account's oldest entry date. ForwardCalculator, ReverseCalculator, and Materializer are updated to use this as their lower bound, allowing entries and balances backfilled before the opening anchor to be included in materialized results. Regression tests cover forward calculation, reverse calculation, and incremental materializer syncs.

Changes

Pre-anchor Balance Materialization

Layer / File(s) Summary
calculation_start_date helper and calculator wiring
app/models/balance/base_calculator.rb, app/models/balance/forward_calculator.rb, app/models/balance/reverse_calculator.rb
Adds the calculation_start_date method to BaseCalculator that returns min(opening_anchor_date, oldest_entry_date). ForwardCalculator returns [0, 0] from opening_starting_balances when the effective start precedes the opening anchor and uses calculation_start_date in the non-incremental branch of calc_start_date. ReverseCalculator#calculate changes its date-loop lower bound from opening_anchor_date to calculation_start_date.
Materializer incremental purge boundary
app/models/balance/materializer.rb
Updates purge_stale_balances to use calculator.calculation_start_date as the lower deletion bound instead of account.opening_anchor_date in both the no-calculated-balances branch (deletion window guard and query) and the balances-present branch (oldest-valid-date preservation logic).
Regression tests for pre-anchor entries
test/models/balance/forward_calculator_test.rb, test/models/balance/reverse_calculator_test.rb, test/models/balance/materializer_test.rb
Adds a forward-calculator regression test verifying that a pre-anchor reconciliation entry is materialized with zero-seeded balances and that subsequent anchor/reconciliation dates reset correctly. Adds a reverse-calculator regression test verifying the loop extends to the earliest entry date and applies reconciliation waypoints on their own dates. Adds a materializer incremental-sync test verifying that pre-anchor balances materialized by a prior full sync are preserved when a window-scoped recalculation is run.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • jjmata

Poem

🐇 Hop back through the ledger, before the anchor was set,
A zero-seed balance is all that you get.
The minimum date now guides where we start,
No entry left stranded, no gap in the chart.
From reconciliation waypoints to purge and arrays,
The rabbit ensures every old entry pays! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(balances): materialize entries dated before the opening anchor' directly and accurately summarizes the main change—enabling materialization of balance entries predating the opening anchor date, which is the core issue addressed throughout the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

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

Copy link
Copy Markdown

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: 07d39a50fe

ℹ️ 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".


def calc_start_date
incremental? ? @window_start_date : account.opening_anchor_date
incremental? ? @window_start_date : calculation_start_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.

P2 Badge Preserve pre-anchor rows during incremental purges

When a manual account has a pre-anchor reconciliation, a full recalculation now creates balances before account.opening_anchor_date, but a later incremental sync still enters this branch for any edit after the anchor. I checked Balance::Materializer#purge_stale_balances: in incremental forward mode it preserves only from account.opening_anchor_date and deletes date < oldest_valid_date, so the next normal sync with window_start_date after the anchor removes the newly materialized pre-anchor rows again. The incremental preservation lower bound needs to use the same start date that can precede the opening anchor.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — confirmed and fixed in the latest push.

Balance::Materializer#purge_stale_balances was still using account.opening_anchor_date as the incremental-purge lower bound in both branches (the sorted_balances.empty? tail-purge and the oldest_valid_date computation), so a later incremental sync after the anchor would delete the pre-anchor rows a full sync had just created.

Fix: exposed Balance::BaseCalculator#calculation_start_date (the same min(opening_anchor_date, oldest_entry_date) bound the calculator iterates from) as a public method and used it in both purge branches instead of opening_anchor_date. Now the preservation bound matches the calculation bound, so pre-anchor balances survive incremental syncs.

Added a regression test: Balance::MaterializerTest#test_incremental_sync_preserves_balances_dated_before_the_opening_anchor — a pre-anchor balance is preserved through an incremental sync whose window starts after the anchor.

@vlnd0 vlnd0 force-pushed the fix/materialize-balances-before-opening-anchor branch 2 times, most recently from 93480c7 to 703e7aa Compare June 21, 2026 20:13

@jjmata jjmata left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The root-cause analysis and fix are solid — bounding on min(opening_anchor_date, oldest_entry_date) is exactly the right model, and the three regression tests cover the important paths (forward seed, reverse loop, incremental purge).

One thing worth considering: calculation_start_date fires an extra entries.minimum(:date) query every time it's called, and within a single sync it's called more than once. Looking at materializer.rb, purge_stale_balances references it on two code paths (the early-return incremental branch and the oldest_valid_date assignment), and the forward/reverse calculators call it via calc_start_date/downto. That's 3–4 extra queries per sync.

A simple memoization would eliminate the redundant hits:

def calculation_start_date
  @calculation_start_date ||= [ account.opening_anchor_date, account.entries.minimum(:date) ].compact.min
end

Since BaseCalculator instances are per-sync and not reused, there's no staleness concern. Worth doing before this lands, especially for accounts with large entry histories where MIN(date) is a non-trivial scan.

Everything else looks great — the return [0, 0] seed guard in opening_starting_balances is clean, the downto(calculation_start_date) bound in the reverse calculator is minimal, and the public visibility with a doc comment is the right call for the materializer to share the same lower bound.


Generated by Claude Code

@vlnd0 vlnd0 force-pushed the fix/materialize-balances-before-opening-anchor branch from 703e7aa to 89584b8 Compare June 22, 2026 06:44
@vlnd0

vlnd0 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Applied the memoization in the latest push:

def calculation_start_date
  @calculation_start_date ||= [ account.opening_anchor_date, account.entries.minimum(:date) ].compact.min
end

Agreed on the reasoning — calculator instances are per-sync so there's no staleness risk, and this collapses the 3–4 MIN(date) scans per sync down to one. Affected tests still green locally (forward/reverse/materializer, 39 runs, 0 failures).

@vlnd0

vlnd0 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@jjmata if all ok, i suggest to merge

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