Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/models/depository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ class Depository < ApplicationRecord
"money_market" => { short: "MM", long: "Money Market" }
}.freeze

# Depository subtypes that carry tax-advantaged treatment in the budget /
# cashflow / income-statement filters (`Family#tax_advantaged_account_ids`,
# `TaxTreatable#tax_advantaged?`). HSA cash sits here because Plaid routes
# `depository.hsa` to `Depository` (not `Investment`) via
# `PlaidAccount::TypeMappable`, so a real-world Plaid-linked HSA cash account
# was previously invisible to the tax-advantaged filter PR #724 introduced.
TAX_ADVANTAGED_SUBTYPES = %w[hsa].freeze

# `TaxTreatable` (the `Account` concern) reads this via `respond_to?` so
# adding it here transparently flips `Account#tax_advantaged?` for HSA
# depositories without touching the concern itself.
#
# Returns `nil` (not `:taxable`) for ordinary depository subtypes. `nil`
# already reads as taxable everywhere it matters: `TaxTreatable#taxable?`
# treats `nil` as taxable and `#tax_advantaged?` excludes it. Returning
# `nil` also keeps `tax_treatment.present?` false so the header tax badge
# (`app/views/accounts/show/_header.html.erb`) stays hidden on checking,
# savings, CD, and money-market accounts that never displayed it before.
def tax_treatment
:tax_advantaged if TAX_ADVANTAGED_SUBTYPES.include?(subtype)
end

class << self
def color
"#875BF7"
Expand Down
14 changes: 13 additions & 1 deletion app/models/family.rb
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ def tax_advantaged_account_ids
.where(cryptos: { tax_treatment: %w[tax_deferred tax_exempt] })
.pluck(:id)

investment_ids + crypto_ids
investment_ids + crypto_ids + tax_advantaged_depository_account_ids
end
end

Expand Down Expand Up @@ -344,6 +344,18 @@ def self_hoster?
end

private
# Mirrors the inline `investment_ids` / `crypto_ids` SQL blocks in
# `tax_advantaged_account_ids`. Joins `depositories` and filters by
# `Depository::TAX_ADVANTAGED_SUBTYPES` (currently `%w[hsa]`). Extracted
# rather than inlined because the existing two blocks are already long
# enough; the extraction keeps `tax_advantaged_account_ids` readable.
def tax_advantaged_depository_account_ids
accounts
.joins("INNER JOIN depositories ON depositories.id = accounts.accountable_id AND accounts.accountable_type = 'Depository'")
.where(depositories: { subtype: Depository::TAX_ADVANTAGED_SUBTYPES })
.pluck(:id)
end

def normalize_enabled_currencies!
if enabled_currencies.blank?
self.enabled_currencies = nil
Expand Down
45 changes: 41 additions & 4 deletions test/models/account_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,32 @@ class AccountTest < ActiveSupport::TestCase
assert_equal I18n.t("accounts.tax_treatments.taxable"), account.tax_treatment_label
end

test "tax_treatment returns nil for non-investment accounts" do
# Depository accounts don't have tax_treatment
test "tax_treatment returns nil for non-HSA depository accounts" do
# Depository exposes a `tax_treatment` method so HSA cash flips
# tax-advantaged, but non-HSA subtypes (checking, savings, cd,
# money_market) return nil. nil still reads as taxable via `taxable?`,
# and keeps `tax_treatment.present?` false so the header tax badge does
# not appear on ordinary bank accounts that never displayed it before.
assert_nil @account.tax_treatment
assert_nil @account.tax_treatment_label
assert_not @account.tax_treatment.present?
assert @account.taxable?
end

test "tax_treatment returns nil for accountables that do not implement it" do
# CreditCard / Loan / Property / OtherAsset / OtherLiability do not
# implement `tax_treatment`, so the `TaxTreatable#respond_to?` short-
# circuit still returns nil for them.
credit_card_account = @family.accounts.create!(
owner: @admin,
name: "Test Credit Card",
balance: 100,
currency: "USD",
accountable: CreditCard.new
)

assert_nil credit_card_account.tax_treatment
assert_nil credit_card_account.tax_treatment_label
end

test "tax_advantaged? returns true for tax-advantaged accounts" do
Expand All @@ -175,6 +197,20 @@ class AccountTest < ActiveSupport::TestCase
assert_not account.taxable?
end

test "tax_advantaged? returns true for HSA depository accounts" do
hsa_depository = @family.accounts.create!(
owner: @admin,
name: "Fidelity HSA Cash",
balance: 3_000,
currency: "USD",
accountable: Depository.new(subtype: "hsa")
)

assert_equal :tax_advantaged, hsa_depository.tax_treatment
assert hsa_depository.tax_advantaged?
assert_not hsa_depository.taxable?
end

test "tax_advantaged? returns false for taxable accounts" do
investment = Investment.new(subtype: "brokerage")
account = @family.accounts.create!(
Expand All @@ -189,8 +225,9 @@ class AccountTest < ActiveSupport::TestCase
assert account.taxable?
end

test "taxable? returns true for accounts without tax_treatment" do
# Depository accounts
test "taxable? returns true for non-HSA depository accounts" do
# `@account` is the checking depository fixture; `tax_treatment` is
# `nil` (no subtype override), which `taxable?` reads as true.
assert @account.taxable?
assert_not @account.tax_advantaged?
end
Expand Down
31 changes: 31 additions & 0 deletions test/models/income_statement_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,37 @@ class IncomeStatementTest < ActiveSupport::TestCase
refute_includes tax_advantaged_ids, @credit_card_account.id
end

test "family.tax_advantaged_account_ids includes HSA depository accounts and excludes non-HSA depositories" do
# Plaid routes `depository.hsa` to `Depository` (not Investment), so HSA
# cash accounts created from a Plaid sync end up as Depository(subtype:
# "hsa") rows. They are semantically tax-advantaged but were previously
# invisible to this filter because it only joined `investments` and
# `cryptos`.
hsa_depository = @family.accounts.create!(
name: "Fidelity HSA Cash",
currency: @family.currency,
balance: 3_000,
accountable: Depository.new(subtype: "hsa")
)

savings_depository = @family.accounts.create!(
name: "Emergency Savings",
currency: @family.currency,
balance: 8_000,
accountable: Depository.new(subtype: "savings")
)

# Clear the memoized value (the setup-block @checking_account is also
# a depository, so we exercise both inclusion and exclusion paths).
@family.instance_variable_set(:@tax_advantaged_account_ids, nil)

tax_advantaged_ids = @family.tax_advantaged_account_ids

assert_includes tax_advantaged_ids, hsa_depository.id
refute_includes tax_advantaged_ids, savings_depository.id
refute_includes tax_advantaged_ids, @checking_account.id
end

# net_category_totals tests
test "net_category_totals nets expense and refund in the same category" do
Entry.joins(:account).where(accounts: { family_id: @family.id }).destroy_all
Expand Down
Loading