Correct brokerage cash calculation for SimpleFIN investment accounts (#710)

* Refactor: Enhance cash balance calculation and holdings management with money market classification and provider-sourced data handling

* Fix: Clear fixture holdings in test to ensure clean creation and update raw_holdings_payload format

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-19 16:17:27 -05:00
committed by GitHub
parent 63dd231787
commit 8b8ac705f6
5 changed files with 125 additions and 36 deletions

View File

@@ -16,6 +16,14 @@ class Holding::Materializer
purge_stale_holdings
end
# Clean up calculated holdings for securities that now have provider-sourced holdings
# This prevents duplicates when a manually-entered account gets linked to a provider
cleanup_calculated_holdings_for_provider_securities
# Reload holdings association to clear any cached stale data
# This ensures subsequent Balance calculations see the fresh holdings
account.holdings.reload
@holdings
end
@@ -39,6 +47,9 @@ class Holding::Materializer
holdings_to_upsert_without_cost = []
@holdings.each do |holding|
# Skip securities that have provider-sourced holdings - don't overwrite provider data
next if provider_sourced_security_ids.include?(holding.security_id)
key = holding_key(holding)
existing = existing_holdings_map[key]
@@ -88,12 +99,37 @@ class Holding::Materializer
# Load holdings that might affect reconciliation:
# - Locked holdings (must preserve their cost_basis)
# - Holdings with a source (need to check priority)
# - Provider-sourced holdings (must not be overwritten)
account.holdings
.where(cost_basis_locked: true)
.or(account.holdings.where.not(cost_basis_source: nil))
.or(account.holdings.where.not(account_provider_id: nil))
.index_by { |h| holding_key(h) }
end
# Get security IDs that have provider-sourced holdings (any date)
# These should be preserved and not overwritten by calculated holdings
def provider_sourced_security_ids
@provider_sourced_security_ids ||= account.holdings
.where.not(account_provider_id: nil)
.distinct
.pluck(:security_id)
end
# Remove calculated holdings (account_provider_id IS NULL) for securities
# that now have provider-sourced holdings. This prevents duplicates when
# a manually-entered account gets linked to a provider.
def cleanup_calculated_holdings_for_provider_securities
return if provider_sourced_security_ids.empty?
deleted_count = account.holdings
.where(account_provider_id: nil)
.where(security_id: provider_sourced_security_ids)
.delete_all
Rails.logger.info("Cleaned up #{deleted_count} calculated holdings for provider-sourced securities") if deleted_count > 0
end
def holding_key(holding)
[ holding.account_id || account.id, holding.security_id, holding.date, holding.currency ]
end
@@ -101,12 +137,16 @@ class Holding::Materializer
def purge_stale_holdings
portfolio_security_ids = account.entries.trades.map { |entry| entry.entryable.security_id }.uniq
# If there are no securities in the portfolio, delete all holdings
# Never delete provider-sourced holdings - they're authoritative from the provider
# If there are no securities in the portfolio, only delete non-provider holdings
if portfolio_security_ids.empty?
Rails.logger.info("Clearing all holdings (no securities)")
account.holdings.delete_all
Rails.logger.info("Clearing non-provider holdings (no securities from trades)")
account.holdings.where(account_provider_id: nil).delete_all
else
deleted_count = account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids)
# Keep provider holdings and holdings for known securities within date range
deleted_count = account.holdings
.where(account_provider_id: nil)
.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids)
Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0
end
end