-
Notifications
You must be signed in to change notification settings - Fork 66
feat: add incremental balance sync windows after provider imports #1880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Resolves the earliest date from which an account balance sync should recalculate | ||
| # when no explicit window was passed on the parent sync. | ||
| class Account::BalanceSyncWindow | ||
| LOOKBACK = 7.days | ||
|
|
||
| class << self | ||
| # @param account [Account] | ||
| # @param parent_sync [Sync, nil] Used to detect entries created/updated during this sync run | ||
| # @param parent_window_start_date [Date, nil] Explicit window from caller or parent sync | ||
| # @param import_window_start_date [Date, nil] Transaction fetch window from provider import | ||
| # @param last_synced_at [Time, nil] Provider item freshness timestamp | ||
| # @return [Date, nil] nil means full balance recalculation | ||
| def for_account(account, parent_sync: nil, parent_window_start_date: nil, import_window_start_date: nil, last_synced_at: nil) | ||
| return parent_window_start_date.to_date if parent_window_start_date.present? | ||
|
|
||
| candidates = [] | ||
| candidates << import_window_start_date.to_date if import_window_start_date.present? | ||
| candidates << entries_touched_since(parent_sync, account) if parent_sync | ||
| candidates << (last_synced_at.to_date - LOOKBACK) if last_synced_at.present? | ||
|
|
||
| window = candidates.compact.min | ||
| return nil unless window | ||
|
|
||
| floor = [ account.opening_anchor_date, account.start_date ].compact.max | ||
| [ window, floor ].max | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def entries_touched_since(sync, account) | ||
| sync_started_at = sync.created_at | ||
| return nil unless sync_started_at | ||
|
|
||
| account.entries | ||
| .where("entries.created_at >= :t OR entries.updated_at >= :t", t: sync_started_at) | ||
| .minimum(:date) | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Shared logic for provider items scheduling per-account balance syncs after import. | ||
| module Account::SchedulesBalanceSyncs | ||
| extend ActiveSupport::Concern | ||
|
|
||
| # @param accounts [Enumerable<Account>, nil] Defaults to {#balance_sync_accounts} | ||
| # @return [Array<Hash>, nil] Result rows when +report_results+ is true; otherwise nil | ||
| def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil, import_window_start_date: nil, accounts: nil, report_results: schedule_account_syncs_report_results?) | ||
| schedule_account_syncs_for( | ||
| accounts || balance_sync_accounts, | ||
| parent_sync: parent_sync, | ||
| window_start_date: window_start_date, | ||
| window_end_date: window_end_date, | ||
| import_window_start_date: import_window_start_date, | ||
| report_results: report_results | ||
| ) | ||
| end | ||
|
|
||
| def schedule_account_syncs_for(accounts, parent_sync: nil, window_start_date: nil, window_end_date: nil, import_window_start_date: nil, report_results: schedule_account_syncs_report_results?) | ||
| return [] if report_results && accounts.blank? | ||
|
|
||
| end_date = window_end_date || parent_sync&.window_end_date | ||
| last_synced = balance_sync_last_synced_at | ||
|
|
||
| iterator = report_results ? :schedule_with_results : :schedule_each | ||
| send(iterator, accounts, parent_sync:, window_start_date:, end_date:, import_window_start_date:, last_synced:) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def schedule_account_syncs_report_results? | ||
| false | ||
| end | ||
|
|
||
| def balance_sync_accounts | ||
| accts = accounts | ||
| accts.respond_to?(:visible) ? accts.visible : accts | ||
| end | ||
|
|
||
| def balance_sync_last_synced_at | ||
| last_synced_at if respond_to?(:last_synced_at) | ||
| end | ||
|
|
||
| def schedule_each(accounts, parent_sync:, window_start_date:, end_date:, import_window_start_date:, last_synced:) | ||
| accounts.each do |account| | ||
| schedule_balance_sync_for_account( | ||
| account, | ||
| parent_sync: parent_sync, | ||
| window_start_date: window_start_date, | ||
| window_end_date: end_date, | ||
| import_window_start_date: import_window_start_date, | ||
| last_synced_at: last_synced | ||
| ) | ||
| end | ||
| nil | ||
| end | ||
|
|
||
| def schedule_with_results(accounts, parent_sync:, window_start_date:, end_date:, import_window_start_date:, last_synced:) | ||
| results = [] | ||
| accounts.each do |account| | ||
| schedule_balance_sync_for_account( | ||
| account, | ||
| parent_sync: parent_sync, | ||
| window_start_date: window_start_date, | ||
| window_end_date: end_date, | ||
| import_window_start_date: import_window_start_date, | ||
| last_synced_at: last_synced | ||
| ) | ||
| results << { account_id: account.id, success: true } | ||
| rescue => e | ||
| Rails.logger.error "#{self.class.name} #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" | ||
| results << { account_id: account.id, success: false, error: e.message } | ||
| end | ||
| results | ||
| end | ||
|
|
||
| def schedule_balance_sync_for_account(account, parent_sync:, window_start_date:, window_end_date:, import_window_start_date:, last_synced_at:) | ||
| effective_window = Account::BalanceSyncWindow.for_account( | ||
| account, | ||
| parent_sync: parent_sync, | ||
| parent_window_start_date: window_start_date || parent_sync&.window_start_date, | ||
| import_window_start_date: import_window_start_date, | ||
| last_synced_at: last_synced_at | ||
| ) | ||
|
|
||
| account.sync_later( | ||
| parent_sync: parent_sync, | ||
| window_start_date: effective_window, | ||
| window_end_date: window_end_date | ||
| ) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,16 @@ | ||
| class Balance::ReverseCalculator < Balance::BaseCalculator | ||
| def initialize(account, window_start_date: nil) | ||
| super(account) | ||
| @window_start_date = window_start_date | ||
| @fell_back = nil | ||
| end | ||
|
|
||
| # True when a window was provided and we successfully limited recalculation to that range. | ||
| def incremental? | ||
| raise "incremental? must not be called before calculate" if @window_start_date.present? && @fell_back.nil? | ||
| @window_start_date.present? && @fell_back == false | ||
| end | ||
|
|
||
| def calculate | ||
| Rails.logger.tagged("Balance::ReverseCalculator") do | ||
| # Since it's a reverse sync, we're starting with the "end of day" balance components and | ||
|
|
@@ -9,8 +21,10 @@ def calculate | |
| ) | ||
| end_non_cash_balance = account.current_anchor_balance - end_cash_balance | ||
|
|
||
| calc_start_date = resolve_calc_start_date | ||
|
|
||
| # Calculates in reverse-chronological order (End of day -> Start of day) | ||
| account.current_anchor_date.downto(account.opening_anchor_date).map do |date| | ||
| account.current_anchor_date.downto(calc_start_date).map do |date| | ||
| flows = flows_for_date(date) | ||
| valuation = sync_cache.get_valuation(date) | ||
|
|
||
|
|
@@ -68,6 +82,40 @@ def calculate | |
|
|
||
| private | ||
|
|
||
| def resolve_calc_start_date | ||
| if @window_start_date.present? | ||
| if multi_currency_account? | ||
| Rails.logger.info("Account has multi-currency entries or is foreign, falling back to full reverse recalculation") | ||
| @fell_back = true | ||
| return account.opening_anchor_date | ||
| end | ||
|
|
||
| prior = prior_balance | ||
|
|
||
| if prior | ||
| Rails.logger.info("Incremental reverse sync from #{@window_start_date}, preserving balances before #{@window_start_date}") | ||
| @fell_back = false | ||
| return [ @window_start_date, account.opening_anchor_date ].max | ||
|
Comment on lines
+95
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| else | ||
| Rails.logger.info("No persisted balance found for #{@window_start_date - 1}, falling back to full reverse recalculation") | ||
| @fell_back = true | ||
| end | ||
| end | ||
|
|
||
| account.opening_anchor_date | ||
| end | ||
|
|
||
| def multi_currency_account? | ||
| account.entries.where.not(currency: account.currency).exists? || | ||
| account.currency != account.family.currency | ||
| end | ||
|
|
||
| def prior_balance | ||
| account.balances | ||
| .where(currency: account.currency) | ||
| .find_by(date: @window_start_date - 1) | ||
| end | ||
|
|
||
| # Negative entries amount on an "asset" account means, "account value has increased" | ||
| # Negative entries amount on a "liability" account means, "account debt has decreased" | ||
| # Positive entries amount on an "asset" account means, "account value has decreased" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apply opening-anchor floor even for explicit parent windows.
Returning early here bypasses the floor in Lines 26-27, so an explicit parent window can go earlier than the account’s valid floor. That breaks the incremental-window contract and can trigger unnecessary backfill recalculation.
Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents