Before you start (required)
General checklist
How are you using Sure?
Self hoster checklist
- Self hosted app commit SHA (find in user menu):
0342958a
- Where are you hosting?
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:
- 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.)
- 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.
- Open the family's IncomeStatement / Cashflow / Budgets surface for the period containing the transaction. The HSA outflow appears in the household expense totals.
- 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
Before you start (required)
General checklist
How are you using Sure?
Self hoster checklist
0342958aBug description
Family#tax_advantaged_account_idsinapp/models/family.rb:243-263is 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 handlesInvestment(viaInvestment::SUBTYPES'stax_treatmentmetadata) andCrypto(via the dedicatedtax_treatmentenum), but silently omitsDepository— including theDepository(subtype: "hsa")variant.Two facts make this matter in production:
Depository::SUBTYPESatapp/models/depository.rb:9already 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:35routes Plaid-reporteddepository.hsaaccounts toDepository(notInvestment). A "Fidelity HSA cash" or similar Plaid-linked HSA cash account ends up as aDepository, not anInvestment, which means it never enters thetax_advantaged_account_idsfilter even though it semantically is one.Account#tax_advantaged?(in theTaxTreatableconcern) callsaccountable.respond_to?(:tax_treatment)and reads the value if present.InvestmentandCryptoboth implementtax_treatment;Depositorydoes not, soaccount.tax_advantaged?returnsfalsefor every HSA depository in the system. This propagates symmetrically intoIncomeStatement::Totals,FamilyStats,CategoryStats, andTransaction::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_treatmentonDepository, drive it from a class-level subtype list (so future expansion happens in one place), and joindepositoriesinFamily#tax_advantaged_account_idsalongside the existinginvestmentsandcryptosjoins. No schema change, no migration, no controller change, no provider integration change.Current behavior
app/models/family.rb:243-263:The docstring on line 240 names HSA explicitly, but the SQL only ever touches
investmentsandcryptos. TheDepositorytable — where Plaid-routed HSA cash accounts actually live — is never queried.Symmetric gap on
Account#tax_advantaged?:Depository#respond_to?(:tax_treatment)returnsfalse, so the predicate isfalsefor every HSA depository regardless of subtype.To Reproduce
Steps to reproduce the behavior:
depository.hsaaccount throughPlaidAccount::TypeMappable.)family.tax_advantaged_account_idsdoes not include the HSA depository's ID, andaccount.tax_advantaged?returnsfalse.Equivalent Minitest reproduction (mirroring the existing
test/models/income_statement_test.rb"tax_advantaged_account_ids returns correct accounts" case):Both assertions fail on
maintoday.Expected behavior
Depositoryexposes atax_treatmentinstance method symmetric toInvestmentandCrypto. Forsubtype == "hsa"it returns:tax_advantaged; otherwise:taxable.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_idsreturnsinvestment_ids + crypto_ids + depository_idswheredepository_idsis computed by an extracted private method that joinsdepositoriesand filters byDepository.tax_advantaged_subtypes, mirroring the existing extraction style for the investments and cryptos branches.Account#tax_advantaged?(inTaxTreatable) returnstruefor HSA depositories without any change to the concern itself — it picks the newDepository#tax_treatmentup via the existingrespond_to?check.IncomeStatement::Totals,FamilyStats,CategoryStats, andTransaction::Searchconsume the corrected ID set transparently — HSA depository contributions / withdrawals stop polluting non-tax-advantaged household totals.tax_treatmentreturns:taxable, they remain absent fromtax_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
Exclude tax-advantaged account activity from budget & add provider data quality...— origin ofFamily#tax_advantaged_account_ids. Explicitly listed HSA as an in-scope account type in the body; the implementation handled Investment and Crypto branches but missed Depository HSA.Add tax treatment classification for investment accounts with international support— introduced theInvestment::SUBTYPES.tax_treatmentmetadata that PR Exclude tax-advantaged account activity from budget & add provider data quality warnings #724 keys off. Same shape thatDepositoryis now missing.fix(reports): exclude investment_contribution from expense totals (#1750)— adjacent in spirit; shows active maintainer interest in budget-classification correctness right now.app/models/plaid_account/type_mappable.rb:35and:68— the Plaid → accountable mapping that routesdepository.hsatoDepository(the upstream reason the bug fires for real users, not just hand-created accounts).