Files
sure/app/models/family.rb
David Gil ba442d5f26 Implement Indexa Capital provider with real API integration (#933)
* Add Indexa Capital provider scaffold

Generate Indexa Capital provider scaffolding and align credential fields with the API authentication requirements.

* Fix PR 926 lint and schema CI failures

* Implement Indexa Capital provider with real API integration

- Rewrite all broken view templates (were meta-ERB from code generator)
- Create missing select_accounts.html.erb template
- Implement real API calls: list_accounts via /users/me, get_holdings
  via /accounts/{number}/fiscal-results, get_account_balance via
  /accounts/{number}/performance
- Add API token auth support (stored token > env token > credentials)
- Add api_token column with encryption support
- Redesign settings panel: API token prominent, credentials collapsible
- Fix account balances display using performance endpoint portfolios
- Fix accounts index empty-state guard missing indexa_capital_items
- Simplify activities fetch job (no activities API endpoint exists)
- Fix i18n interpolation (%%{ -> %{) throughout locale file

* Add tests for Indexa Capital provider integration

- IndexaCapitalItemTest: validations, credentials, scopes, sync status
- IndexaCapitalAccountTest: upsert, holdings, account provider linking
- Provider::IndexaCapitalTest: auth modes, API stubs, error handling
- IndexaCapitalItemsControllerTest: CRUD, setup, linking, authorization
- Fixtures for items (token + credentials) and accounts (mutual + pension)

52 tests, 98 assertions, 0 failures

* Address code review feedback from PR #933

- Fix zero balance bug: use `nil?` instead of `present?` so 0 is stored
- Fix has_indexa_capital_credentials? to check api_token (was ignored)
- Fix build_provider to delegate to Provided concern (was ignoring token)
- Fix IndexaCapital section outside encryption_error guard in settings
- Add account_number sanitization to prevent path traversal in API URLs
- Replace all skipped processor tests with real working tests
- Add zero-balance and path-traversal test coverage

61 tests, 107 assertions, 0 failures

* Address code review round 2: credentials validation, RuboCop, test quality

- Fix RuboCop SpaceInsideArrayLiteralBrackets in credentials check
- Chain where.not calls so all three username/document/password must be present
- Require all three credentials (||) instead of any one (&&) in validate_configuration!
- Move attr_reader to private to avoid exposing credentials publicly
- Parse dates with Date.parse in extract_balance for robustness
- Remove stale TODO and Crypto from supported_account_types
- Order build_provider query deterministically by created_at
- Replace no-op holdings assertion with meaningful assert_difference

* Address code review round 3: JSON parse safety and test precision

- Rescue JSON::ParserError on 2xx responses for clearer error messages
- Fix weak balance assertion: set balance to 0 before processing, assert
  expected value (27093.01 = sum of holdings amounts)

* Include Indexa Capital in automatic family sync

Add indexa_capital_items to Family::Syncer#child_syncables so balances
and holdings refresh on daily auto-sync and login sync, not only on
manual sync button clicks.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-02-08 18:19:37 +01:00

228 lines
8.0 KiB
Ruby

class Family < ApplicationRecord
include IndexaCapitalConnectable
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "DD.MM.YYYY", "%d.%m.%Y" ],
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ],
[ "YYYYMMDD", "%Y%m%d" ]
].freeze
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :invitations, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :family_exports, dependent: :destroy
has_many :entries, through: :accounts
has_many :transactions, through: :accounts
has_many :rules, dependent: :destroy
has_many :trades, through: :accounts
has_many :holdings, through: :accounts
has_many :tags, dependent: :destroy
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy, class_name: "FamilyMerchant"
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
has_many :llm_usages, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
validates :month_start_day, inclusion: { in: 1..28 }
def uses_custom_month_start?
month_start_day != 1
end
def custom_month_start_for(date)
if date.day >= month_start_day
Date.new(date.year, date.month, month_start_day)
else
previous_month = date - 1.month
Date.new(previous_month.year, previous_month.month, month_start_day)
end
end
def custom_month_end_for(date)
start_date = custom_month_start_for(date)
next_month_start = start_date + 1.month
next_month_start - 1.day
end
def current_custom_month_period
start_date = custom_month_start_for(Date.current)
end_date = custom_month_end_for(Date.current)
Period.custom(start_date: start_date, end_date: end_date)
end
def assigned_merchants
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
Merchant.where(id: merchant_ids)
end
def available_merchants
assigned_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
recently_unlinked_ids = FamilyMerchantAssociation
.where(family: self)
.recently_unlinked
.pluck(:merchant_id)
family_merchant_ids = merchants.pluck(:id)
Merchant.where(id: (assigned_ids + recently_unlinked_ids + family_merchant_ids).uniq)
end
def auto_categorize_transactions_later(transactions, rule_run_id: nil)
AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end
def auto_categorize_transactions(transaction_ids)
AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize
end
def auto_detect_transaction_merchants_later(transactions, rule_run_id: nil)
AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
end
def auto_detect_transaction_merchants(transaction_ids)
AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect
end
def balance_sheet
@balance_sheet ||= BalanceSheet.new(self)
end
def income_statement
@income_statement ||= IncomeStatement.new(self)
end
# Returns the Investment Contributions category for this family, creating it if it doesn't exist.
# This is used for auto-categorizing transfers to investment accounts.
def investment_contributions_category
categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat|
cat.color = "#0d9488"
cat.classification = "expense"
cat.lucide_icon = "trending-up"
end
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
categories.find_by(name: Category.investment_contributions_name)
end
# Returns account IDs for tax-advantaged accounts (401k, IRA, HSA, etc.)
# Used to exclude these accounts from budget/cashflow calculations.
# Tax-advantaged accounts are retirement savings, not daily expenses.
def tax_advantaged_account_ids
@tax_advantaged_account_ids ||= begin
# Investment accounts derive tax_treatment from subtype
tax_advantaged_subtypes = Investment::SUBTYPES.select do |_, meta|
meta[:tax_treatment].in?(%i[tax_deferred tax_exempt tax_advantaged])
end.keys
investment_ids = accounts
.joins("INNER JOIN investments ON investments.id = accounts.accountable_id AND accounts.accountable_type = 'Investment'")
.where(investments: { subtype: tax_advantaged_subtypes })
.pluck(:id)
# Crypto accounts have an explicit tax_treatment column
crypto_ids = accounts
.joins("INNER JOIN cryptos ON cryptos.id = accounts.accountable_id AND accounts.accountable_type = 'Crypto'")
.where(cryptos: { tax_treatment: %w[tax_deferred tax_exempt] })
.pluck(:id)
investment_ids + crypto_ids
end
end
def investment_statement
@investment_statement ||= InvestmentStatement.new(self)
end
def eu?
country != "US" && country != "CA"
end
def requires_securities_data_provider?
# If family has any trades, they need a provider for historical prices
trades.any?
end
def requires_exchange_rates_data_provider?
# If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates
return true if accounts.where.not(currency: self.currency).any?
# If family has any entries in different currencies, they need a provider for historical exchange rates
uniq_currencies = entries.pluck(:currency).uniq
return true if uniq_currencies.count > 1
return true if uniq_currencies.count > 0 && uniq_currencies.first != self.currency
false
end
def missing_data_provider?
(requires_securities_data_provider? && Security.provider.nil?) ||
(requires_exchange_rates_data_provider? && ExchangeRate.provider.nil?)
end
# Returns securities with plan restrictions for a specific provider
# @param provider [String] The provider name (e.g., "TwelveData")
# @return [Array<Hash>] Array of hashes with ticker, name, required_plan, provider
def securities_with_plan_restrictions(provider:)
security_ids = trades.joins(:security).pluck("securities.id").uniq
return [] if security_ids.empty?
restrictions = Security.plan_restrictions_for(security_ids, provider: provider)
return [] if restrictions.empty?
Security.where(id: restrictions.keys).map do |security|
restriction = restrictions[security.id]
{
ticker: security.ticker,
name: security.name,
required_plan: restriction[:required_plan],
provider: restriction[:provider]
}
end
end
def oldest_entry_date
entries.order(:date).first&.date || Date.current
end
# Used for invalidating family / balance sheet related aggregation queries
def build_cache_key(key, invalidate_on_data_updates: false)
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
# By including it in the cache key, we can expire caches every time family account data changes.
data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil
[
id,
key,
data_invalidation_key,
accounts.maximum(:updated_at)
].compact.join("_")
end
# Used for invalidating entry related aggregation queries
def entries_cache_version
@entries_cache_version ||= begin
ts = entries.maximum(:updated_at)
ts.present? ? ts.to_i : 0
end
end
def self_hoster?
Rails.application.config.app_mode.self_hosted?
end
end