diff --git a/app/models/account.rb b/app/models/account.rb index 305cc1fd0..d4cdf0684 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable + include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable validates :name, :balance, :currency, presence: true diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index d7f2a3315..3b764c245 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -31,11 +31,17 @@ module Accountable end # Given a subtype, look up the label for this accountable type + # Uses i18n with fallback to hardcoded SUBTYPES values def subtype_label_for(subtype, format: :short) return nil if subtype.nil? label_type = format == :long ? :long : :short - self::SUBTYPES[subtype]&.fetch(label_type, nil) + fallback = self::SUBTYPES.dig(subtype, label_type) + + I18n.t( + "#{name.underscore.pluralize}.subtypes.#{subtype}.#{label_type}", + default: fallback + ) end # Convenience method for getting the short label diff --git a/app/models/concerns/tax_treatable.rb b/app/models/concerns/tax_treatable.rb new file mode 100644 index 000000000..1aa85638d --- /dev/null +++ b/app/models/concerns/tax_treatable.rb @@ -0,0 +1,26 @@ +module TaxTreatable + extend ActiveSupport::Concern + + # Delegates tax treatment to the accountable (Investment or Crypto) + # Returns nil for account types that don't support tax treatment + def tax_treatment + return nil unless accountable.respond_to?(:tax_treatment) + accountable.tax_treatment&.to_sym + end + + # Returns the i18n label for the tax treatment + def tax_treatment_label + return nil unless tax_treatment + I18n.t("accounts.tax_treatments.#{tax_treatment}") + end + + # Returns true if the account has tax advantages (deferred, exempt, or advantaged) + def tax_advantaged? + tax_treatment.in?(%i[tax_deferred tax_exempt tax_advantaged]) + end + + # Returns true if gains in this account are taxable + def taxable? + tax_treatment == :taxable || tax_treatment.nil? + end +end diff --git a/app/models/crypto.rb b/app/models/crypto.rb index 7724a9535..16e894265 100644 --- a/app/models/crypto.rb +++ b/app/models/crypto.rb @@ -1,6 +1,14 @@ class Crypto < ApplicationRecord include Accountable + # Crypto is taxable by default, but can be held in tax-advantaged accounts + # (e.g., self-directed IRA, though rare) + enum :tax_treatment, { + taxable: "taxable", + tax_deferred: "tax_deferred", + tax_exempt: "tax_exempt" + }, default: :taxable + class << self def color "#737373" diff --git a/app/models/investment.rb b/app/models/investment.rb index f8b44e8b9..e701b2f80 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -1,29 +1,63 @@ class Investment < ApplicationRecord include Accountable + # Tax treatment categories: + # - taxable: Gains taxed when realized + # - tax_deferred: Taxes deferred until withdrawal + # - tax_exempt: Qualified gains are tax-free + # - tax_advantaged: Special tax benefits with conditions SUBTYPES = { - "brokerage" => { short: "Brokerage", long: "Brokerage" }, - "pension" => { short: "Pension", long: "Pension" }, - "retirement" => { short: "Retirement", long: "Retirement" }, - "401k" => { short: "401(k)", long: "401(k)" }, - "roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)" }, - "403b" => { short: "403(b)", long: "403(b)" }, - "457b" => { short: "457(b)", long: "457(b)" }, - "tsp" => { short: "TSP", long: "Thrift Savings Plan" }, - "529_plan" => { short: "529 Plan", long: "529 Plan" }, - "hsa" => { short: "HSA", long: "Health Savings Account" }, - "mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund" }, - "ira" => { short: "IRA", long: "Traditional IRA" }, - "roth_ira" => { short: "Roth IRA", long: "Roth IRA" }, - "sep_ira" => { short: "SEP IRA", long: "SEP IRA" }, - "simple_ira" => { short: "SIMPLE IRA", long: "SIMPLE IRA" }, - "angel" => { short: "Angel", long: "Angel" }, - "trust" => { short: "Trust", long: "Trust" }, - "ugma" => { short: "UGMA", long: "UGMA" }, - "utma" => { short: "UTMA", long: "UTMA" }, - "other" => { short: "Other", long: "Other Investment" } + # === United States === + "brokerage" => { short: "Brokerage", long: "Brokerage", region: "us", tax_treatment: :taxable }, + "401k" => { short: "401(k)", long: "401(k)", region: "us", tax_treatment: :tax_deferred }, + "roth_401k" => { short: "Roth 401(k)", long: "Roth 401(k)", region: "us", tax_treatment: :tax_exempt }, + "403b" => { short: "403(b)", long: "403(b)", region: "us", tax_treatment: :tax_deferred }, + "457b" => { short: "457(b)", long: "457(b)", region: "us", tax_treatment: :tax_deferred }, + "tsp" => { short: "TSP", long: "Thrift Savings Plan", region: "us", tax_treatment: :tax_deferred }, + "ira" => { short: "IRA", long: "Traditional IRA", region: "us", tax_treatment: :tax_deferred }, + "roth_ira" => { short: "Roth IRA", long: "Roth IRA", region: "us", tax_treatment: :tax_exempt }, + "sep_ira" => { short: "SEP IRA", long: "SEP IRA", region: "us", tax_treatment: :tax_deferred }, + "simple_ira" => { short: "SIMPLE IRA", long: "SIMPLE IRA", region: "us", tax_treatment: :tax_deferred }, + "529_plan" => { short: "529 Plan", long: "529 Education Savings Plan", region: "us", tax_treatment: :tax_advantaged }, + "hsa" => { short: "HSA", long: "Health Savings Account", region: "us", tax_treatment: :tax_advantaged }, + "ugma" => { short: "UGMA", long: "UGMA Custodial Account", region: "us", tax_treatment: :taxable }, + "utma" => { short: "UTMA", long: "UTMA Custodial Account", region: "us", tax_treatment: :taxable }, + + # === United Kingdom === + "isa" => { short: "ISA", long: "Individual Savings Account", region: "uk", tax_treatment: :tax_exempt }, + "lisa" => { short: "LISA", long: "Lifetime ISA", region: "uk", tax_treatment: :tax_exempt }, + "sipp" => { short: "SIPP", long: "Self-Invested Personal Pension", region: "uk", tax_treatment: :tax_deferred }, + "workplace_pension_uk" => { short: "Pension", long: "Workplace Pension", region: "uk", tax_treatment: :tax_deferred }, + + # === Canada === + "rrsp" => { short: "RRSP", long: "Registered Retirement Savings Plan", region: "ca", tax_treatment: :tax_deferred }, + "tfsa" => { short: "TFSA", long: "Tax-Free Savings Account", region: "ca", tax_treatment: :tax_exempt }, + "resp" => { short: "RESP", long: "Registered Education Savings Plan", region: "ca", tax_treatment: :tax_advantaged }, + "lira" => { short: "LIRA", long: "Locked-In Retirement Account", region: "ca", tax_treatment: :tax_deferred }, + "rrif" => { short: "RRIF", long: "Registered Retirement Income Fund", region: "ca", tax_treatment: :tax_deferred }, + + # === Australia === + "super" => { short: "Super", long: "Superannuation", region: "au", tax_treatment: :tax_deferred }, + "smsf" => { short: "SMSF", long: "Self-Managed Super Fund", region: "au", tax_treatment: :tax_deferred }, + + # === Europe === + "pea" => { short: "PEA", long: "Plan d'Épargne en Actions", region: "eu", tax_treatment: :tax_advantaged }, + "pillar_3a" => { short: "Pillar 3a", long: "Private Pension (Pillar 3a)", region: "eu", tax_treatment: :tax_deferred }, + "riester" => { short: "Riester", long: "Riester-Rente", region: "eu", tax_treatment: :tax_deferred }, + + # === Generic (available everywhere) === + "pension" => { short: "Pension", long: "Pension", region: nil, tax_treatment: :tax_deferred }, + "retirement" => { short: "Retirement", long: "Retirement Account", region: nil, tax_treatment: :tax_deferred }, + "mutual_fund" => { short: "Mutual Fund", long: "Mutual Fund", region: nil, tax_treatment: :taxable }, + "angel" => { short: "Angel", long: "Angel Investment", region: nil, tax_treatment: :taxable }, + "trust" => { short: "Trust", long: "Trust", region: nil, tax_treatment: :taxable }, + "other" => { short: "Other", long: "Other Investment", region: nil, tax_treatment: :taxable } }.freeze + def tax_treatment + SUBTYPES.dig(subtype, :tax_treatment) || :taxable + end + class << self def color "#1570EF" @@ -36,5 +70,9 @@ class Investment < ApplicationRecord def icon "chart-line" end + + def region_label_for(region) + I18n.t("accounts.subtype_regions.#{region || 'generic'}") + end end end diff --git a/app/views/accounts/_tax_treatment_badge.html.erb b/app/views/accounts/_tax_treatment_badge.html.erb new file mode 100644 index 000000000..070e870ba --- /dev/null +++ b/app/views/accounts/_tax_treatment_badge.html.erb @@ -0,0 +1,17 @@ +<%# locals: (account:) %> +<% + treatment = account.tax_treatment + badge_classes = case treatment + when :tax_exempt + "bg-green-500/10 text-green-600 theme-dark:text-green-400" + when :tax_deferred + "bg-blue-500/10 text-blue-600 theme-dark:text-blue-400" + when :tax_advantaged + "bg-purple-500/10 text-purple-600 theme-dark:text-purple-400" + else + "bg-gray-500/10 text-secondary" + end +%> +"> + <%= account.tax_treatment_label %> + diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 6d151059a..41d731236 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -9,6 +9,9 @@

"><%= title %>

+ <% if account.tax_treatment.present? %> + <%= render partial: "accounts/tax_treatment_badge", locals: { account: account } %> + <% end %> <% if account.draft? %> <%= render DS::Link.new( text: "Complete setup", diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index c321cfba3..939d175af 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -103,6 +103,23 @@ en: credit_card: Credit Card loan: Loan other_liability: Other Liability + tax_treatments: + taxable: Taxable + tax_deferred: Tax-Deferred + tax_exempt: Tax-Exempt + tax_advantaged: Tax-Advantaged + tax_treatment_descriptions: + taxable: Gains taxed when realized + tax_deferred: Contributions deductible, taxed on withdrawal + tax_exempt: Contributions after-tax, gains not taxed + tax_advantaged: Special tax benefits with conditions + subtype_regions: + us: United States + uk: United Kingdom + ca: Canada + au: Australia + eu: Europe + generic: General confirm_unlink: title: Unlink account from provider? description_html: "You are about to unlink %{account_name} from %{provider_name}. This will convert it to a manual account." diff --git a/config/locales/views/investments/en.yml b/config/locales/views/investments/en.yml index 451609e3b..7a4875ba1 100644 --- a/config/locales/views/investments/en.yml +++ b/config/locales/views/investments/en.yml @@ -10,6 +10,115 @@ en: title: Enter account balance show: chart_title: Total value + subtypes: + # United States + brokerage: + short: Brokerage + long: Brokerage + 401k: + short: 401(k) + long: 401(k) + roth_401k: + short: Roth 401(k) + long: Roth 401(k) + 403b: + short: 403(b) + long: 403(b) + 457b: + short: 457(b) + long: 457(b) + tsp: + short: TSP + long: Thrift Savings Plan + ira: + short: IRA + long: Traditional IRA + roth_ira: + short: Roth IRA + long: Roth IRA + sep_ira: + short: SEP IRA + long: SEP IRA + simple_ira: + short: SIMPLE IRA + long: SIMPLE IRA + 529_plan: + short: 529 Plan + long: 529 Education Savings Plan + hsa: + short: HSA + long: Health Savings Account + ugma: + short: UGMA + long: UGMA Custodial Account + utma: + short: UTMA + long: UTMA Custodial Account + # United Kingdom + isa: + short: ISA + long: Individual Savings Account + lisa: + short: LISA + long: Lifetime ISA + sipp: + short: SIPP + long: Self-Invested Personal Pension + workplace_pension_uk: + short: Pension + long: Workplace Pension + # Canada + rrsp: + short: RRSP + long: Registered Retirement Savings Plan + tfsa: + short: TFSA + long: Tax-Free Savings Account + resp: + short: RESP + long: Registered Education Savings Plan + lira: + short: LIRA + long: Locked-In Retirement Account + rrif: + short: RRIF + long: Registered Retirement Income Fund + # Australia + super: + short: Super + long: Superannuation + smsf: + short: SMSF + long: Self-Managed Super Fund + # Europe + pea: + short: PEA + long: Plan d'Épargne en Actions + pillar_3a: + short: Pillar 3a + long: Private Pension (Pillar 3a) + riester: + short: Riester + long: Riester-Rente + # Generic + pension: + short: Pension + long: Pension + retirement: + short: Retirement + long: Retirement Account + mutual_fund: + short: Mutual Fund + long: Mutual Fund + angel: + short: Angel + long: Angel Investment + trust: + short: Trust + long: Trust + other: + short: Other + long: Other Investment value_tooltip: cash: Cash holdings: Holdings diff --git a/db/migrate/20260117200000_add_tax_treatment_to_cryptos.rb b/db/migrate/20260117200000_add_tax_treatment_to_cryptos.rb new file mode 100644 index 000000000..e25fc1838 --- /dev/null +++ b/db/migrate/20260117200000_add_tax_treatment_to_cryptos.rb @@ -0,0 +1,5 @@ +class AddTaxTreatmentToCryptos < ActiveRecord::Migration[7.2] + def change + add_column :cryptos, :tax_treatment, :string, default: "taxable", null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index d7a150a26..976bb9da4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_16_090336) do +ActiveRecord::Schema[7.2].define(version: 2026_01_17_200000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -256,6 +256,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_16_090336) do t.datetime "updated_at", null: false t.jsonb "locked_attributes", default: {} t.string "subtype" + t.string "tax_treatment", default: "taxable", null: false end create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 911b0bfa4..8a52192af 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -89,4 +89,70 @@ class AccountTest < ActiveSupport::TestCase assert_equal "Investments", account.short_subtype_label assert_equal "Investments", account.long_subtype_label end + + # Tax treatment tests (TaxTreatable concern) + + test "tax_treatment delegates to accountable for Investment" do + investment = Investment.new(subtype: "401k") + account = @family.accounts.create!( + name: "Test 401k", + balance: 1000, + currency: "USD", + accountable: investment + ) + + assert_equal :tax_deferred, account.tax_treatment + assert_equal I18n.t("accounts.tax_treatments.tax_deferred"), account.tax_treatment_label + end + + test "tax_treatment delegates to accountable for Crypto" do + crypto = Crypto.new(tax_treatment: :taxable) + account = @family.accounts.create!( + name: "Test Crypto", + balance: 500, + currency: "USD", + accountable: crypto + ) + + assert_equal :taxable, account.tax_treatment + 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 + assert_nil @account.tax_treatment + assert_nil @account.tax_treatment_label + end + + test "tax_advantaged? returns true for tax-advantaged accounts" do + investment = Investment.new(subtype: "401k") + account = @family.accounts.create!( + name: "Test 401k", + balance: 1000, + currency: "USD", + accountable: investment + ) + + assert account.tax_advantaged? + assert_not account.taxable? + end + + test "tax_advantaged? returns false for taxable accounts" do + investment = Investment.new(subtype: "brokerage") + account = @family.accounts.create!( + name: "Test Brokerage", + balance: 1000, + currency: "USD", + accountable: investment + ) + + assert_not account.tax_advantaged? + assert account.taxable? + end + + test "taxable? returns true for accounts without tax_treatment" do + # Depository accounts + assert @account.taxable? + assert_not @account.tax_advantaged? + end end diff --git a/test/models/crypto_test.rb b/test/models/crypto_test.rb new file mode 100644 index 000000000..d32ba009b --- /dev/null +++ b/test/models/crypto_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class CryptoTest < ActiveSupport::TestCase + test "tax_treatment defaults to taxable" do + crypto = Crypto.new + assert_equal "taxable", crypto.tax_treatment + end + + test "tax_treatment can be set to tax_deferred" do + crypto = Crypto.new(tax_treatment: :tax_deferred) + assert_equal "tax_deferred", crypto.tax_treatment + end + + test "tax_treatment can be set to tax_exempt" do + crypto = Crypto.new(tax_treatment: :tax_exempt) + assert_equal "tax_exempt", crypto.tax_treatment + end + + test "tax_treatment enum provides predicate methods" do + crypto = Crypto.new(tax_treatment: :taxable) + assert crypto.taxable? + assert_not crypto.tax_deferred? + assert_not crypto.tax_exempt? + + crypto.tax_treatment = :tax_deferred + assert_not crypto.taxable? + assert crypto.tax_deferred? + assert_not crypto.tax_exempt? + end +end diff --git a/test/models/investment_test.rb b/test/models/investment_test.rb new file mode 100644 index 000000000..dbae27153 --- /dev/null +++ b/test/models/investment_test.rb @@ -0,0 +1,139 @@ +require "test_helper" + +class InvestmentTest < ActiveSupport::TestCase + # Tax treatment derivation tests + + test "tax_treatment returns tax_deferred for US retirement accounts" do + %w[401k 403b 457b tsp ira sep_ira simple_ira].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_deferred, investment.tax_treatment, "Expected #{subtype} to be tax_deferred" + end + end + + test "tax_treatment returns tax_exempt for Roth accounts" do + %w[roth_401k roth_ira].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_exempt, investment.tax_treatment, "Expected #{subtype} to be tax_exempt" + end + end + + test "tax_treatment returns tax_advantaged for special accounts" do + %w[529_plan hsa].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_advantaged, investment.tax_treatment, "Expected #{subtype} to be tax_advantaged" + end + end + + test "tax_treatment returns taxable for standard accounts" do + %w[brokerage mutual_fund angel trust ugma utma other].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :taxable, investment.tax_treatment, "Expected #{subtype} to be taxable" + end + end + + test "tax_treatment returns taxable for nil subtype" do + investment = Investment.new(subtype: nil) + assert_equal :taxable, investment.tax_treatment + end + + test "tax_treatment returns taxable for unknown subtype" do + investment = Investment.new(subtype: "unknown_type") + assert_equal :taxable, investment.tax_treatment + end + + # UK account types + + test "tax_treatment returns tax_exempt for UK ISA accounts" do + %w[isa lisa].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_exempt, investment.tax_treatment, "Expected #{subtype} to be tax_exempt" + end + end + + test "tax_treatment returns tax_deferred for UK pension accounts" do + %w[sipp workplace_pension_uk].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_deferred, investment.tax_treatment, "Expected #{subtype} to be tax_deferred" + end + end + + # Canadian account types + + test "tax_treatment returns tax_deferred for Canadian retirement accounts" do + %w[rrsp lira rrif].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_deferred, investment.tax_treatment, "Expected #{subtype} to be tax_deferred" + end + end + + test "tax_treatment returns tax_exempt for Canadian TFSA" do + investment = Investment.new(subtype: "tfsa") + assert_equal :tax_exempt, investment.tax_treatment + end + + test "tax_treatment returns tax_advantaged for Canadian RESP" do + investment = Investment.new(subtype: "resp") + assert_equal :tax_advantaged, investment.tax_treatment + end + + # Australian account types + + test "tax_treatment returns tax_deferred for Australian super accounts" do + %w[super smsf].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_deferred, investment.tax_treatment, "Expected #{subtype} to be tax_deferred" + end + end + + # European account types + + test "tax_treatment returns tax_deferred for European pension accounts" do + %w[pillar_3a riester].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_deferred, investment.tax_treatment, "Expected #{subtype} to be tax_deferred" + end + end + + test "tax_treatment returns tax_advantaged for French PEA" do + investment = Investment.new(subtype: "pea") + assert_equal :tax_advantaged, investment.tax_treatment + end + + # Generic account types + + test "tax_treatment returns tax_deferred for generic pension and retirement" do + %w[pension retirement].each do |subtype| + investment = Investment.new(subtype: subtype) + assert_equal :tax_deferred, investment.tax_treatment, "Expected #{subtype} to be tax_deferred" + end + end + + # Subtype metadata tests + + test "all subtypes have required metadata keys" do + Investment::SUBTYPES.each do |key, metadata| + assert metadata.key?(:short), "Subtype #{key} missing :short key" + assert metadata.key?(:long), "Subtype #{key} missing :long key" + assert metadata.key?(:tax_treatment), "Subtype #{key} missing :tax_treatment key" + assert metadata.key?(:region), "Subtype #{key} missing :region key" + end + end + + test "all subtypes have valid tax_treatment values" do + valid_treatments = %i[taxable tax_deferred tax_exempt tax_advantaged] + + Investment::SUBTYPES.each do |key, metadata| + assert_includes valid_treatments, metadata[:tax_treatment], + "Subtype #{key} has invalid tax_treatment: #{metadata[:tax_treatment]}" + end + end + + test "all subtypes have valid region values" do + valid_regions = [ "us", "uk", "ca", "au", "eu", nil ] + + Investment::SUBTYPES.each do |key, metadata| + assert_includes valid_regions, metadata[:region], + "Subtype #{key} has invalid region: #{metadata[:region]}" + end + end +end