diff --git a/app/models/coinstats_item/syncer.rb b/app/models/coinstats_item/syncer.rb index 76f7343bb..2e97ce796 100644 --- a/app/models/coinstats_item/syncer.rb +++ b/app/models/coinstats_item/syncer.rb @@ -1,6 +1,8 @@ # Orchestrates the sync process for a CoinStats connection. # Imports data, processes holdings, and schedules account syncs. class CoinstatsItem::Syncer + include SyncStats::Collector + attr_reader :coinstats_item # @param coinstats_item [CoinstatsItem] Item to sync @@ -40,6 +42,10 @@ class CoinstatsItem::Syncer sync.update!(status_text: I18n.t("models.coinstats_item.syncer.processing_holdings")) if sync.respond_to?(:status_text) coinstats_item.process_accounts + # CoinStats provides transactions but not activity labels (Buy, Sell, Dividend, etc.) + # Warn users that this may affect budget accuracy + collect_investment_data_quality_warning(sync, linked_accounts) + # Phase 4: Schedule balance calculations for linked accounts sync.update!(status_text: I18n.t("models.coinstats_item.syncer.calculating_balances")) if sync.respond_to?(:status_text) coinstats_item.schedule_account_syncs( @@ -58,4 +64,22 @@ class CoinstatsItem::Syncer def perform_post_sync # no-op end + + private + + # Collects a data quality warning for all CoinStats accounts. + # CoinStats cannot provide activity labels (Buy, Sell, Dividend, etc.) for transactions, + # which may affect budget accuracy. + def collect_investment_data_quality_warning(sync, linked_coinstats_accounts) + # All CoinStats accounts are crypto/investment accounts + return if linked_coinstats_accounts.empty? + + collect_data_quality_stats(sync, + warnings: linked_coinstats_accounts.size, + details: [ { + message: I18n.t("provider_warnings.limited_investment_data"), + severity: "warning" + } ] + ) + end end diff --git a/app/models/family.rb b/app/models/family.rb index 4feb0be00..a69e9629b 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -86,6 +86,31 @@ class Family < ApplicationRecord 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 diff --git a/app/models/income_statement/category_stats.rb b/app/models/income_statement/category_stats.rb index 0552ebc62..6f774722e 100644 --- a/app/models/income_statement/category_stats.rb +++ b/app/models/income_statement/category_stats.rb @@ -21,14 +21,29 @@ class IncomeStatement::CategoryStats def sanitized_query_sql ActiveRecord::Base.sanitize_sql_array([ query_sql, - { - target_currency: @family.currency, - interval: @interval, - family_id: @family.id - } + sql_params ]) end + def sql_params + params = { + target_currency: @family.currency, + interval: @interval, + family_id: @family.id + } + + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + params + end + + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + def query_sql <<~SQL WITH period_totals AS ( @@ -51,6 +66,7 @@ class IncomeStatement::CategoryStats AND ae.excluded = false AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + #{exclude_tax_advantaged_sql} GROUP BY c.id, period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/income_statement/family_stats.rb b/app/models/income_statement/family_stats.rb index bfaae6fa5..73959e9a3 100644 --- a/app/models/income_statement/family_stats.rb +++ b/app/models/income_statement/family_stats.rb @@ -20,14 +20,29 @@ class IncomeStatement::FamilyStats def sanitized_query_sql ActiveRecord::Base.sanitize_sql_array([ query_sql, - { - target_currency: @family.currency, - interval: @interval, - family_id: @family.id - } + sql_params ]) end + def sql_params + params = { + target_currency: @family.currency, + interval: @interval, + family_id: @family.id + } + + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + params + end + + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" + end + def query_sql <<~SQL WITH period_totals AS ( @@ -48,6 +63,7 @@ class IncomeStatement::FamilyStats AND ae.excluded = false AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true + #{exclude_tax_advantaged_sql} GROUP BY period, CASE WHEN t.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END ) SELECT diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 7bb9f67dc..028de5638 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -71,8 +71,9 @@ class IncomeStatement::Totals ) WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment') AND ae.excluded = false - AND a.family_id = :family_id + AND a.family_id = :family_id AND a.status IN ('draft', 'active') + #{exclude_tax_advantaged_sql} GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END; SQL end @@ -101,8 +102,9 @@ class IncomeStatement::Totals OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') ) AND ae.excluded = false - AND a.family_id = :family_id + AND a.family_id = :family_id AND a.status IN ('draft', 'active') + #{exclude_tax_advantaged_sql} GROUP BY c.id, c.parent_id, CASE WHEN at.kind = 'investment_contribution' THEN 'expense' WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END SQL end @@ -120,12 +122,26 @@ class IncomeStatement::Totals end def sql_params - { + params = { target_currency: @family.currency, family_id: @family.id, start_date: @date_range.begin, end_date: @date_range.end } + + # Add tax-advantaged account IDs if any exist + ids = @family.tax_advantaged_account_ids + params[:tax_advantaged_account_ids] = ids if ids.present? + + params + end + + # Returns SQL clause to exclude tax-advantaged accounts from budget calculations. + # Tax-advantaged accounts (401k, IRA, HSA, etc.) are retirement savings, not daily expenses. + def exclude_tax_advantaged_sql + ids = @family.tax_advantaged_account_ids + return "" if ids.empty? + "AND a.id NOT IN (:tax_advantaged_account_ids)" end def validate_date_range! diff --git a/app/models/lunchflow_item/syncer.rb b/app/models/lunchflow_item/syncer.rb index 73147c4ef..92975eadc 100644 --- a/app/models/lunchflow_item/syncer.rb +++ b/app/models/lunchflow_item/syncer.rb @@ -36,6 +36,9 @@ class LunchflowItem::Syncer lunchflow_item.process_accounts Rails.logger.info "LunchflowItem::Syncer - Finished processing accounts" + # Warn about limited investment data for investment/crypto accounts + collect_investment_data_quality_warning(sync, linked_accounts) + # Phase 4: Schedule balance calculations for linked accounts sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) lunchflow_item.schedule_account_syncs( @@ -61,4 +64,26 @@ class LunchflowItem::Syncer def perform_post_sync # no-op end + + private + + # Collects a data quality warning if any linked accounts are investment or crypto accounts. + # Lunchflow cannot provide activity labels (Buy, Sell, Dividend, etc.) for investment transactions, + # which may affect budget accuracy. + def collect_investment_data_quality_warning(sync, linked_lunchflow_accounts) + investment_accounts = linked_lunchflow_accounts.select do |la| + account = la.current_account + account&.accountable_type.in?(%w[Investment Crypto]) + end + + return if investment_accounts.empty? + + collect_data_quality_stats(sync, + warnings: investment_accounts.size, + details: [ { + message: I18n.t("provider_warnings.limited_investment_data"), + severity: "warning" + } ] + ) + end end diff --git a/app/models/simplefin_item/syncer.rb b/app/models/simplefin_item/syncer.rb index 343e71e08..e8276a895 100644 --- a/app/models/simplefin_item/syncer.rb +++ b/app/models/simplefin_item/syncer.rb @@ -69,6 +69,9 @@ class SimplefinItem::Syncer collect_skip_stats(sync, skipped_entries: skipped_entries) end + # Warn about limited investment data for investment/crypto accounts + collect_investment_data_quality_warning(sync, linked_simplefin_accounts) + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) simplefin_item.schedule_account_syncs( parent_sync: sync, @@ -225,6 +228,26 @@ class SimplefinItem::Syncer } end + # Collects a data quality warning if any linked accounts are investment or crypto accounts. + # SimpleFIN cannot provide activity labels (Buy, Sell, Dividend, etc.) for investment transactions, + # which may affect budget accuracy. + def collect_investment_data_quality_warning(sync, linked_simplefin_accounts) + investment_accounts = linked_simplefin_accounts.select do |sfa| + account = sfa.current_account + account&.accountable_type.in?(%w[Investment Crypto]) + end + + return if investment_accounts.empty? + + collect_data_quality_stats(sync, + warnings: investment_accounts.size, + details: [ { + message: I18n.t("provider_warnings.limited_investment_data"), + severity: "warning" + } ] + ) + end + def mark_failed(sync, error) # If already completed, do not attempt to fail to avoid AASM InvalidTransition if sync.respond_to?(:status) && sync.status.to_s == "completed" diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index b4311415f..242527c16 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -44,10 +44,18 @@ class Transaction::Search end # Computes totals for the specific search + # Note: Excludes tax-advantaged accounts (401k, IRA, etc.) from totals calculation + # because those transactions are retirement savings, not daily income/expenses. def totals @totals ||= begin Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do - result = transactions_scope + scope = transactions_scope + + # Exclude tax-advantaged accounts from totals calculation + tax_advantaged_ids = family.tax_advantaged_account_ids + scope = scope.where.not(accounts: { id: tax_advantaged_ids }) if tax_advantaged_ids.present? + + result = scope .select( "COALESCE(SUM(CASE WHEN transactions.kind = 'investment_contribution' THEN ABS(entries.amount * COALESCE(er.rate, 1)) WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total", "COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total", @@ -74,7 +82,8 @@ class Transaction::Search [ family.id, Digest::SHA256.hexdigest(attributes.sort.to_h.to_json), # cached by filters - family.entries_cache_version + family.entries_cache_version, + Digest::SHA256.hexdigest(family.tax_advantaged_account_ids.sort.to_json) # stable across processes ].join("/") end diff --git a/config/locales/models/provider_warnings/ca.yml b/config/locales/models/provider_warnings/ca.yml new file mode 100644 index 000000000..a78bd889d --- /dev/null +++ b/config/locales/models/provider_warnings/ca.yml @@ -0,0 +1,4 @@ +--- +ca: + provider_warnings: + limited_investment_data: "Les dades d'inversió d'aquest proveïdor són limitades. Les etiquetes d'activitat (Compra, Venda, Dividend) no estan disponibles, cosa que pot afectar la precisió del pressupost. Considereu crear regles per excloure o categoritzar les transaccions d'inversió." diff --git a/config/locales/models/provider_warnings/de.yml b/config/locales/models/provider_warnings/de.yml new file mode 100644 index 000000000..b770e39db --- /dev/null +++ b/config/locales/models/provider_warnings/de.yml @@ -0,0 +1,4 @@ +--- +de: + provider_warnings: + limited_investment_data: "Die Investitionsdaten dieses Anbieters sind begrenzt. Aktivitätskennzeichnungen (Kauf, Verkauf, Dividende) sind nicht verfügbar, was die Budgetgenauigkeit beeinträchtigen kann. Erwägen Sie, Regeln zu erstellen, um Investitionstransaktionen auszuschließen oder zu kategorisieren." diff --git a/config/locales/models/provider_warnings/en.yml b/config/locales/models/provider_warnings/en.yml new file mode 100644 index 000000000..1d3549c21 --- /dev/null +++ b/config/locales/models/provider_warnings/en.yml @@ -0,0 +1,4 @@ +--- +en: + provider_warnings: + limited_investment_data: "Investment data from this provider is limited. Activity labels (Buy, Sell, Dividend) are not available, which may affect budget accuracy. Consider creating rules to exclude or categorize investment transactions." diff --git a/config/locales/models/provider_warnings/es.yml b/config/locales/models/provider_warnings/es.yml new file mode 100644 index 000000000..c8a5b8aa1 --- /dev/null +++ b/config/locales/models/provider_warnings/es.yml @@ -0,0 +1,4 @@ +--- +es: + provider_warnings: + limited_investment_data: "Los datos de inversión de este proveedor son limitados. Las etiquetas de actividad (Compra, Venta, Dividendo) no están disponibles, lo que puede afectar la precisión del presupuesto. Considere crear reglas para excluir o categorizar las transacciones de inversión." diff --git a/config/locales/models/provider_warnings/fr.yml b/config/locales/models/provider_warnings/fr.yml new file mode 100644 index 000000000..3ced05340 --- /dev/null +++ b/config/locales/models/provider_warnings/fr.yml @@ -0,0 +1,4 @@ +--- +fr: + provider_warnings: + limited_investment_data: "Les données d'investissement de ce fournisseur sont limitées. Les étiquettes d'activité (Achat, Vente, Dividende) ne sont pas disponibles, ce qui peut affecter la précision du budget. Pensez à créer des règles pour exclure ou catégoriser les transactions d'investissement." diff --git a/config/locales/models/provider_warnings/nb.yml b/config/locales/models/provider_warnings/nb.yml new file mode 100644 index 000000000..a20d8d26f --- /dev/null +++ b/config/locales/models/provider_warnings/nb.yml @@ -0,0 +1,4 @@ +--- +nb: + provider_warnings: + limited_investment_data: "Investeringsdata fra denne leverandøren er begrenset. Aktivitetsetiketter (Kjøp, Salg, Utbytte) er ikke tilgjengelige, noe som kan påvirke budsjettnøyaktigheten. Vurder å opprette regler for å ekskludere eller kategorisere investeringstransaksjoner." diff --git a/config/locales/models/provider_warnings/nl.yml b/config/locales/models/provider_warnings/nl.yml new file mode 100644 index 000000000..f07865db1 --- /dev/null +++ b/config/locales/models/provider_warnings/nl.yml @@ -0,0 +1,4 @@ +--- +nl: + provider_warnings: + limited_investment_data: "Investeringsgegevens van deze provider zijn beperkt. Activiteitslabels (Koop, Verkoop, Dividend) zijn niet beschikbaar, wat de nauwkeurigheid van het budget kan beïnvloeden. Overweeg regels te maken om investeringstransacties uit te sluiten of te categoriseren." diff --git a/config/locales/models/provider_warnings/pt-BR.yml b/config/locales/models/provider_warnings/pt-BR.yml new file mode 100644 index 000000000..7f909d695 --- /dev/null +++ b/config/locales/models/provider_warnings/pt-BR.yml @@ -0,0 +1,4 @@ +--- +pt-BR: + provider_warnings: + limited_investment_data: "Os dados de investimento deste provedor são limitados. Rótulos de atividade (Compra, Venda, Dividendo) não estão disponíveis, o que pode afetar a precisão do orçamento. Considere criar regras para excluir ou categorizar transações de investimento." diff --git a/config/locales/models/provider_warnings/ro.yml b/config/locales/models/provider_warnings/ro.yml new file mode 100644 index 000000000..faf3ff667 --- /dev/null +++ b/config/locales/models/provider_warnings/ro.yml @@ -0,0 +1,4 @@ +--- +ro: + provider_warnings: + limited_investment_data: "Datele de investiții de la acest furnizor sunt limitate. Etichetele de activitate (Cumpărare, Vânzare, Dividend) nu sunt disponibile, ceea ce poate afecta acuratețea bugetului. Luați în considerare crearea de reguli pentru a exclude sau categoriza tranzacțiile de investiții." diff --git a/config/locales/models/provider_warnings/tr.yml b/config/locales/models/provider_warnings/tr.yml new file mode 100644 index 000000000..a69cf9114 --- /dev/null +++ b/config/locales/models/provider_warnings/tr.yml @@ -0,0 +1,4 @@ +--- +tr: + provider_warnings: + limited_investment_data: "Bu sağlayıcıdan alınan yatırım verileri sınırlıdır. İşlem etiketleri (Al, Sat, Temettü) mevcut olmadığından bütçe doğruluğu etkilenebilir. Yatırım işlemlerini hariç tutmak veya kategorize etmek için kurallar oluşturmayı düşünün." diff --git a/config/locales/models/provider_warnings/zh-CN.yml b/config/locales/models/provider_warnings/zh-CN.yml new file mode 100644 index 000000000..485e746c1 --- /dev/null +++ b/config/locales/models/provider_warnings/zh-CN.yml @@ -0,0 +1,4 @@ +--- +zh-CN: + provider_warnings: + limited_investment_data: "此提供商的投资数据有限。活动标签(买入、卖出、股息)不可用,这可能会影响预算准确性。请考虑创建规则以排除或分类投资交易。" diff --git a/config/locales/models/provider_warnings/zh-TW.yml b/config/locales/models/provider_warnings/zh-TW.yml new file mode 100644 index 000000000..957b056ad --- /dev/null +++ b/config/locales/models/provider_warnings/zh-TW.yml @@ -0,0 +1,4 @@ +--- +zh-TW: + provider_warnings: + limited_investment_data: "此提供商的投資數據有限。活動標籤(買入、賣出、股息)不可用,這可能會影響預算準確性。請考慮建立規則以排除或分類投資交易。" diff --git a/test/models/coinstats_item/syncer_test.rb b/test/models/coinstats_item/syncer_test.rb index 1e63becad..a4839482f 100644 --- a/test/models/coinstats_item/syncer_test.rb +++ b/test/models/coinstats_item/syncer_test.rb @@ -65,6 +65,7 @@ class CoinstatsItem::SyncerTest < ActiveSupport::TestCase mock_sync = mock("sync") mock_sync.stubs(:respond_to?).with(:status_text).returns(true) mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true) + mock_sync.stubs(:sync_stats).returns({}) mock_sync.stubs(:window_start_date).returns(nil) mock_sync.stubs(:window_end_date).returns(nil) mock_sync.expects(:update!).at_least_once @@ -96,6 +97,7 @@ class CoinstatsItem::SyncerTest < ActiveSupport::TestCase mock_sync = mock("sync") mock_sync.stubs(:respond_to?).with(:status_text).returns(true) mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true) + mock_sync.stubs(:sync_stats).returns({}) mock_sync.stubs(:window_start_date).returns(nil) mock_sync.stubs(:window_end_date).returns(nil) mock_sync.expects(:update!).at_least_once @@ -150,6 +152,7 @@ class CoinstatsItem::SyncerTest < ActiveSupport::TestCase mock_sync = mock("sync") mock_sync.stubs(:respond_to?).with(:status_text).returns(true) mock_sync.stubs(:respond_to?).with(:sync_stats).returns(true) + mock_sync.stubs(:sync_stats).returns({}) mock_sync.stubs(:window_start_date).returns(nil) mock_sync.stubs(:window_end_date).returns(nil) mock_sync.expects(:update!).at_least_once.with do |args| diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb index 7231626a2..14280ec1c 100644 --- a/test/models/income_statement_test.rb +++ b/test/models/income_statement_test.rb @@ -333,4 +333,228 @@ class IncomeStatementTest < ActiveSupport::TestCase assert_equal Money.new(1000, @family.currency), totals.income_money assert_equal Money.new(1400, @family.currency), totals.expense_money # 900 + 500 (abs of -500) end + + # Tax-Advantaged Account Exclusion Tests + test "excludes transactions from tax-advantaged Roth IRA accounts" do + # Create a Roth IRA (tax-exempt) investment account + roth_ira = @family.accounts.create!( + name: "Roth IRA", + currency: @family.currency, + balance: 50000, + accountable: Investment.new(subtype: "roth_ira") + ) + + # Create a dividend transaction in the Roth IRA + # This should NOT appear in budget totals + create_transaction(account: roth_ira, amount: -200, category: @income_category) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + # The Roth IRA dividend should be excluded + assert_equal 4, totals.transactions_count # Only original 4 transactions + assert_equal Money.new(1000, @family.currency), totals.income_money + assert_equal Money.new(900, @family.currency), totals.expense_money + end + + test "excludes transactions from tax-deferred 401k accounts" do + # Create a 401k (tax-deferred) investment account + account_401k = @family.accounts.create!( + name: "Company 401k", + currency: @family.currency, + balance: 100000, + accountable: Investment.new(subtype: "401k") + ) + + # Create a dividend transaction in the 401k + # This should NOT appear in budget totals + create_transaction(account: account_401k, amount: -500, category: @income_category) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + # The 401k dividend should be excluded + assert_equal 4, totals.transactions_count + assert_equal Money.new(1000, @family.currency), totals.income_money + assert_equal Money.new(900, @family.currency), totals.expense_money + end + + test "includes transactions from taxable brokerage accounts" do + # Create a taxable brokerage account + brokerage = @family.accounts.create!( + name: "Brokerage", + currency: @family.currency, + balance: 25000, + accountable: Investment.new(subtype: "brokerage") + ) + + # Create a dividend transaction in the taxable account + # This SHOULD appear in budget totals + create_transaction(account: brokerage, amount: -300, category: @income_category) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + # The brokerage dividend SHOULD be included + assert_equal 5, totals.transactions_count + assert_equal Money.new(1300, @family.currency), totals.income_money # 1000 + 300 + assert_equal Money.new(900, @family.currency), totals.expense_money + end + + test "includes transactions from default taxable crypto accounts" do + # Create a crypto account (default taxable) + crypto_account = @family.accounts.create!( + name: "Coinbase", + currency: @family.currency, + balance: 5000, + accountable: Crypto.new + ) + + # Create a transaction in the crypto account + create_transaction(account: crypto_account, amount: 100, category: @groceries_category) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + # Crypto transaction SHOULD be included (default is taxable) + assert_equal 5, totals.transactions_count + assert_equal Money.new(1000, @family.currency), totals.expense_money # 900 + 100 + end + + test "excludes transactions from tax-deferred crypto accounts" do + # Create a crypto account in a tax-deferred retirement account + crypto_in_ira = @family.accounts.create!( + name: "Crypto IRA", + currency: @family.currency, + balance: 10000, + accountable: Crypto.new(tax_treatment: "tax_deferred") + ) + + # Create a transaction in the tax-deferred crypto account + create_transaction(account: crypto_in_ira, amount: 250, category: @groceries_category) + + income_statement = IncomeStatement.new(@family) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + # The tax-deferred crypto transaction should be excluded + assert_equal 4, totals.transactions_count + assert_equal Money.new(900, @family.currency), totals.expense_money + end + + test "family.tax_advantaged_account_ids returns correct accounts" do + # Create various accounts + roth_ira = @family.accounts.create!( + name: "Roth IRA", + currency: @family.currency, + balance: 50000, + accountable: Investment.new(subtype: "roth_ira") + ) + + traditional_ira = @family.accounts.create!( + name: "Traditional IRA", + currency: @family.currency, + balance: 30000, + accountable: Investment.new(subtype: "ira") + ) + + brokerage = @family.accounts.create!( + name: "Brokerage", + currency: @family.currency, + balance: 25000, + accountable: Investment.new(subtype: "brokerage") + ) + + crypto_taxable = @family.accounts.create!( + name: "Crypto Taxable", + currency: @family.currency, + balance: 5000, + accountable: Crypto.new + ) + + crypto_deferred = @family.accounts.create!( + name: "Crypto IRA", + currency: @family.currency, + balance: 10000, + accountable: Crypto.new(tax_treatment: "tax_deferred") + ) + + # Clear the memoized value + @family.instance_variable_set(:@tax_advantaged_account_ids, nil) + + tax_advantaged_ids = @family.tax_advantaged_account_ids + + # Should include Roth IRA, Traditional IRA, and tax-deferred Crypto + assert_includes tax_advantaged_ids, roth_ira.id + assert_includes tax_advantaged_ids, traditional_ira.id + assert_includes tax_advantaged_ids, crypto_deferred.id + + # Should NOT include taxable accounts + refute_includes tax_advantaged_ids, brokerage.id + refute_includes tax_advantaged_ids, crypto_taxable.id + + # Should NOT include non-investment accounts + refute_includes tax_advantaged_ids, @checking_account.id + refute_includes tax_advantaged_ids, @credit_card_account.id + end + + test "returns zero totals when family has only tax-advantaged accounts" do + # Create a fresh family with ONLY tax-advantaged accounts + family_only_retirement = Family.create!( + name: "Retirement Only Family", + currency: "USD", + locale: "en", + date_format: "%Y-%m-%d" + ) + + # Create a 401k account (tax-deferred) + retirement_account = family_only_retirement.accounts.create!( + name: "401k", + currency: "USD", + balance: 100000, + accountable: Investment.new(subtype: "401k") + ) + + # Create a Roth IRA account (tax-exempt) + roth_account = family_only_retirement.accounts.create!( + name: "Roth IRA", + currency: "USD", + balance: 50000, + accountable: Investment.new(subtype: "roth_ira") + ) + + # Add transactions to these accounts (would normally be contributions/trades) + # Using standard kind to simulate transactions that would normally appear + Entry.create!( + account: retirement_account, + name: "401k Contribution", + date: 5.days.ago, + amount: 500, + currency: "USD", + entryable: Transaction.new(kind: "standard") + ) + + Entry.create!( + account: roth_account, + name: "Roth IRA Contribution", + date: 3.days.ago, + amount: 200, + currency: "USD", + entryable: Transaction.new(kind: "standard") + ) + + # Verify the accounts are correctly identified as tax-advantaged + tax_advantaged_ids = family_only_retirement.tax_advantaged_account_ids + assert_equal 2, tax_advantaged_ids.count + assert_includes tax_advantaged_ids, retirement_account.id + assert_includes tax_advantaged_ids, roth_account.id + + # Get income statement totals + income_statement = IncomeStatement.new(family_only_retirement) + totals = income_statement.totals(date_range: Period.last_30_days.date_range) + + # All transactions should be excluded, resulting in zero totals + assert_equal 0, totals.transactions_count + assert_equal Money.new(0, "USD"), totals.income_money + assert_equal Money.new(0, "USD"), totals.expense_money + end end