Add tax treatment classification for investment accounts with international support (#693)

* Add tax treatment support for accounts, investments, and cryptos

* Replace hardcoded region labels with I18n translations

* Add I18n support for subtype labels with fallback to hardcoded values

* fixed schema

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
This commit is contained in:
LPW
2026-01-18 11:29:02 -05:00
committed by GitHub
parent 6ec03e93f4
commit 64dc5c2fb8
14 changed files with 488 additions and 23 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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
%>
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium <%= badge_classes %>" title="<%= t("accounts.tax_treatment_descriptions.#{treatment}") %>">
<%= account.tax_treatment_label %>
</span>

View File

@@ -9,6 +9,9 @@
<div class="truncate">
<div class="flex items-center gap-3">
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title %></h2>
<% 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",

View File

@@ -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 <strong>%{account_name}</strong> from <strong>%{provider_name}</strong>. This will convert it to a manual account."

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddTaxTreatmentToCryptos < ActiveRecord::Migration[7.2]
def change
add_column :cryptos, :tax_treatment, :string, default: "taxable", null: false
end
end

3
db/schema.rb generated
View File

@@ -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|

View File

@@ -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

View File

@@ -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

View File

@@ -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