Skip to content

Bug: Family#tax_advantaged_account_ids omits HSA depository accounts #2003

Description

@galuis116

Before you start (required)

General checklist

  • I have removed personal / sensitive data from screenshots and logs
  • I have searched existing issues and discussions to ensure this is not a duplicate issue

How are you using Sure?

  • I was a paying Maybe customer (hosted version)
  • I use it on PikaPod, Umbrel or similar (VPS included)
  • I am a self-hosted user (local only)

Self hoster checklist

  • Self hosted app commit SHA (find in user menu): 0342958a
    • I have confirmed that my app's commit is the latest version of Sure
  • Where are you hosting?
    • Render
    • Docker Compose
    • Umbrel
    • PikaPod
    • Other (please specify)

Bug description

Family#tax_advantaged_account_ids in app/models/family.rb:243-263 is the ID set that the budget engine uses to exclude tax-advantaged account activity from income / expense / cashflow totals. PR #724 (Exclude tax-advantaged account activity from budget & add provider data quality...) originated this method and explicitly listed HSA as an in-scope account type ("401k, IRA, HSA, Roth IRA, etc."). The implementation correctly handles Investment (via Investment::SUBTYPES's tax_treatment metadata) and Crypto (via the dedicated tax_treatment enum), but silently omits Depository — including the Depository(subtype: "hsa") variant.

Two facts make this matter in production:

  • Depository::SUBTYPES at app/models/depository.rb:9 already includes "hsa" => { short: "HSA", long: "Health Savings Account" }, so HSA depositories are constructable from the UI and from data importers today.
  • app/models/plaid_account/type_mappable.rb:35 routes Plaid-reported depository.hsa accounts to Depository (not Investment). A "Fidelity HSA cash" or similar Plaid-linked HSA cash account ends up as a Depository, not an Investment, which means it never enters the tax_advantaged_account_ids filter even though it semantically is one.

Account#tax_advantaged? (in the TaxTreatable concern) calls accountable.respond_to?(:tax_treatment) and reads the value if present. Investment and Crypto both implement tax_treatment; Depository does not, so account.tax_advantaged? returns false for every HSA depository in the system. This propagates symmetrically into IncomeStatement::Totals, FamilyStats, CategoryStats, and Transaction::Search — every analytics path that PR #724 was designed to suppress tax-advantaged activity from. HSA contributions and withdrawals show up in monthly expense totals, contaminating the household's actual non-tax-advantaged spending picture.

The fix is symmetric to the existing branches: surface tax_treatment on Depository, drive it from a class-level subtype list (so future expansion happens in one place), and join depositories in Family#tax_advantaged_account_ids alongside the existing investments and cryptos joins. No schema change, no migration, no controller change, no provider integration change.

Current behavior

app/models/family.rb:243-263:

# 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

The docstring on line 240 names HSA explicitly, but the SQL only ever touches investments and cryptos. The Depository table — where Plaid-routed HSA cash accounts actually live — is never queried.

Symmetric gap on Account#tax_advantaged?: Depository#respond_to?(:tax_treatment) returns false, so the predicate is false for every HSA depository regardless of subtype.

To Reproduce

Steps to reproduce the behavior:

  1. In any family, create a depository account with the HSA subtype — either through the UI (Accounts → Add → Depository → choose subtype "HSA") or via console:
    family.accounts.create!(
      owner: admin, name: "Fidelity HSA Cash", balance: 3_000, currency: "USD",
      accountable: Depository.new(subtype: "hsa")
    )
    (The same code path runs when Plaid surfaces a depository.hsa account through PlaidAccount::TypeMappable.)
  2. Add a transaction to that account categorized as a routine expense (e.g. an HSA-eligible medical bill paid from the HSA cash). Confirm the transaction is created normally.
  3. Open the family's IncomeStatement / Cashflow / Budgets surface for the period containing the transaction. The HSA outflow appears in the household expense totals.
  4. In Rails console, confirm family.tax_advantaged_account_ids does not include the HSA depository's ID, and account.tax_advantaged? returns false.

Equivalent Minitest reproduction (mirroring the existing test/models/income_statement_test.rb "tax_advantaged_account_ids returns correct accounts" case):

test "tax_advantaged_account_ids includes HSA depository accounts" do
  hsa_depo = @family.accounts.create!(
    owner: @admin, name: "Fidelity HSA Cash", balance: 3_000, currency: "USD",
    accountable: Depository.new(subtype: "hsa")
  )
  @family.instance_variable_set(:@tax_advantaged_account_ids, nil)

  assert_includes @family.tax_advantaged_account_ids, hsa_depo.id
  assert hsa_depo.tax_advantaged?
end

Both assertions fail on main today.

Expected behavior

  • Depository exposes a tax_treatment instance method symmetric to Investment and Crypto. For subtype == "hsa" it returns :tax_advantaged; otherwise :taxable.
  • The list of tax-advantaged depository subtypes lives on a single class-level accessor (e.g. Depository.tax_advantaged_subtypes) so future additions (HSA-CD, FSA-equivalent depository subtypes, jurisdiction-specific variants) happen in one place rather than in two SQL strings.
  • Family#tax_advantaged_account_ids returns investment_ids + crypto_ids + depository_ids where depository_ids is computed by an extracted private method that joins depositories and filters by Depository.tax_advantaged_subtypes, mirroring the existing extraction style for the investments and cryptos branches.
  • Account#tax_advantaged? (in TaxTreatable) returns true for HSA depositories without any change to the concern itself — it picks the new Depository#tax_treatment up via the existing respond_to? check.
  • IncomeStatement::Totals, FamilyStats, CategoryStats, and Transaction::Search consume the corrected ID set transparently — HSA depository contributions / withdrawals stop polluting non-tax-advantaged household totals.
  • Non-HSA depositories (Checking, Savings, CD, Money Market, etc.) are unaffected: their tax_treatment returns :taxable, they remain absent from tax_advantaged_account_ids, and existing tests that assert "depository is taxable" pass unchanged.

Screenshots and/or recordings

Not applicable — backend accounting / classification bug; the visible symptom (HSA outflows polluting the household expense total in the Budgets / Cashflow / Reports views) is downstream of the model-layer ID set. Reproduction is server-side via Rails console or the Minitest stub above.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions