1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
use serde::{Deserialize, Serialize};
use super::Dollars;
use super::allocation::Allocation;
use super::triggers::Trigger;
/// The wrapper an account lives in. Roth treatment is the separate
/// [`Account::roth`] flag, since several wrappers hold both.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AccountKind {
/// Employer 401(k).
#[serde(rename = "401k")]
K401k,
/// Employer 403(b).
#[serde(rename = "403b")]
K403b,
/// Governmental 457(b); exempt from the early-withdrawal penalty.
#[serde(rename = "457b")]
K457b,
/// Pension-attached defined-contribution account.
#[serde(rename = "414k")]
K414k,
/// Traditional or rollover IRA.
#[serde(rename = "ira")]
Ira,
/// SEP IRA; employer contributions only.
#[serde(rename = "sep-ira")]
SepIra,
/// SIMPLE IRA.
#[serde(rename = "simple-ira")]
SimpleIra,
/// Health savings account.
#[serde(rename = "hsa")]
Hsa,
/// Taxable brokerage.
#[serde(rename = "brokerage")]
Brokerage,
/// Cash, savings, CDs.
#[serde(rename = "cash")]
Cash,
}
impl AccountKind {
/// Every kind, in the order the schema declares them.
pub const ALL: &'static [Self] = &[
Self::K401k,
Self::K403b,
Self::K457b,
Self::K414k,
Self::Ira,
Self::SepIra,
Self::SimpleIra,
Self::Hsa,
Self::Brokerage,
Self::Cash,
];
/// The kind as a plan file spells it.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::K401k => "401k",
Self::K403b => "403b",
Self::K457b => "457b",
Self::K414k => "414k",
Self::Ira => "ira",
Self::SepIra => "sep-ira",
Self::SimpleIra => "simple-ira",
Self::Hsa => "hsa",
Self::Brokerage => "brokerage",
Self::Cash => "cash",
}
}
/// The tax treatment class for this wrapper with the given Roth flag.
#[must_use]
pub fn treatment(self, is_roth: bool) -> TreatmentClass {
match self {
Self::Brokerage | Self::Cash => TreatmentClass::Taxable,
Self::Hsa => TreatmentClass::Hsa,
_ if is_roth => TreatmentClass::Roth,
_ => TreatmentClass::Deferred,
}
}
/// Whether this wrapper can hold Roth money.
#[must_use]
pub fn supports_roth(self) -> bool {
matches!(self, Self::K401k | Self::K403b | Self::K457b | Self::Ira)
}
/// Whether employer contributions are valid here.
#[must_use]
pub fn accepts_employer(self) -> bool {
matches!(
self,
Self::K401k
| Self::K403b
| Self::K457b
| Self::K414k
| Self::SepIra
| Self::SimpleIra
| Self::Hsa
)
}
/// Whether an employee's after-tax contributions are valid here: a
/// 401(k), 403(b) or 414(k).
#[must_use]
pub fn accepts_after_tax(self) -> bool {
matches!(self, Self::K401k | Self::K403b | Self::K414k)
}
/// Whether the kind keeps an untaxed part of its balance: cost basis
/// where gains are taxed, after-tax basis where withdrawals are, and
/// what was paid in where only an early withdrawal's earnings are.
#[must_use]
pub fn keeps_basis(self, is_roth: bool) -> bool {
self.tracks_basis()
|| matches!(
self.treatment(is_roth),
TreatmentClass::Deferred | TreatmentClass::Roth
)
}
/// Whether the kind is an IRA, whose after-tax basis the law pools per
/// person with the owner's other IRAs.
#[must_use]
pub fn is_ira(self) -> bool {
matches!(self, Self::Ira | Self::SepIra | Self::SimpleIra)
}
/// Whether leaving the job a plan of this kind is with can free it from
/// the early-withdrawal penalty: a 401(k), 403(b) or 414(k).
#[must_use]
pub fn frees_on_separation(self) -> bool {
matches!(self, Self::K401k | Self::K403b | Self::K414k)
}
/// Whether employee contributions are valid here.
#[must_use]
pub fn accepts_employee(self) -> bool {
!matches!(self, Self::SepIra)
}
/// Whether the account tracks cost basis for capital-gains treatment.
#[must_use]
pub fn tracks_basis(self) -> bool {
matches!(self, Self::Brokerage)
}
}
/// Tax treatment classes, also the vocabulary of the withdrawal order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "kebab-case")]
pub enum TreatmentClass {
/// Taxed as it is realized: brokerage, cash.
Taxable,
/// Tax-deferred: taxed as ordinary income on withdrawal.
Deferred,
/// Roth: what was paid in comes back untaxed, and what it earned does
/// once the owner is 59½ and the account five years old.
Roth,
/// HSA: withdrawals untaxed up to the year's medical spending, and
/// taxed beyond it.
Hsa,
}
impl TreatmentClass {
/// Every class, in the order the schema declares them.
pub const ALL: &'static [Self] = &[Self::Taxable, Self::Deferred, Self::Roth, Self::Hsa];
/// The class as a plan file spells it.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Taxable => "taxable",
Self::Deferred => "deferred",
Self::Roth => "roth",
Self::Hsa => "hsa",
}
}
}
/// A fund bucket.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Account {
/// Unique id other plan items reference.
pub id: String,
/// Display name; defaults to the id.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// The wrapper.
pub kind: AccountKind,
/// Roth treatment within the wrapper.
#[serde(default)]
pub roth: bool,
/// Owning person id.
pub owner: String,
/// Balance at plan start, in dollars.
pub balance: Dollars,
/// The untaxed part of the balance at plan start: cost basis on a
/// brokerage and what was paid into a Roth account, each defaulting to
/// the balance; after-tax basis on a tax-deferred account, defaulting
/// to nothing.
#[serde(skip_serializing_if = "Option::is_none")]
pub basis: Option<Dollars>,
/// A fixed annual return, never varying; none earns nothing. Refused
/// beside an allocation.
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_return: Option<f64>,
/// What the account is invested in, which its return follows.
#[serde(skip_serializing_if = "Option::is_none")]
pub allocation: Option<Allocation>,
/// When the owner leaves the job this workplace plan is with; leaving it
/// in or after the year they turn 55 frees the plan from the
/// early-withdrawal penalty from that year on.
#[serde(skip_serializing_if = "Option::is_none")]
pub separated: Option<Trigger>,
/// The owner is a public-safety employee of the plan's employer, which
/// lowers the age `separated` is checked against to 50.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub public_safety: bool,
/// Until this fires, the account cannot be drained or transferred from.
#[serde(skip_serializing_if = "Option::is_none")]
pub locked_until: Option<Trigger>,
/// Drains before every treatment class; lower values first.
#[serde(skip_serializing_if = "Option::is_none")]
pub drain_priority: Option<u32>,
}
impl Account {
/// The account's tax treatment class.
#[must_use]
pub fn treatment(&self) -> TreatmentClass {
self.kind.treatment(self.roth)
}
/// Whether the account keeps an untaxed part of its balance.
#[must_use]
pub fn keeps_basis(&self) -> bool {
self.kind.keeps_basis(self.roth)
}
/// The untaxed part of the balance at plan start.
#[must_use]
pub fn starting_basis(&self) -> Dollars {
let is_whole = self.kind.tracks_basis() || self.treatment() == TreatmentClass::Roth;
let whole = if is_whole { self.balance } else { 0 };
self.basis.unwrap_or(whole)
}
}