# HSA Formulation Sanity Check

*Report date: March 7, 2025*

This document audits the HSA (Health Savings Account) implementation in Owl against IRS rules (IRC §223, IRS Publication 969) and cross-references with documentation and web sources.

---

## 1. IRS HSA Rules (Summary)

| Rule | IRS Requirement | Source |
|------|-----------------|--------|
| **Contribution limits (2026)** | $4,400 self-only; $8,750 family; +$1,000 catch-up at 55+ | IRC §223(b)(2), (c)(2)(B); IRS Pub 969 |
| **Eligibility** | Must be enrolled in HDHP; no Medicare; not a dependent | IRC §223(c)(1) |
| **Medicare disqualification** | Enrollment in any Medicare Part A/B/C/D disqualifies contributions | IRC §223(b)(7); Pub 969 |
| **Mid-year Medicare** | IRS allows prorated contributions for months before enrollment | Form 8889, Pub 969 |
| **Contributions reduce AGI** | Above-the-line deduction | IRC §223(a) |
| **Qualified withdrawals** | Tax-free for medical expenses at any age | IRC §223(f)(2) |
| **Non-qualified pre-65** | Ordinary income + 20% penalty | IRC §223(f)(4)(A) |
| **Non-qualified 65+** | Ordinary income only (no penalty) | IRC §223(f)(4)(B) |
| **Spouse beneficiary** | Inherits intact; treats as own HSA | IRC §223(f)(8)(B) |
| **Non-spouse beneficiary** | Full balance taxable as ordinary income | IRC §223(f)(8)(B) |

---

## 2. Implementation vs Documentation

### 2.1 Documentation

- **`papers/owl.tex`** (Sec. Health Savings Accounts): Describes rules, implementation, and limitations.
- **`docs/modeling-capabilities.md`**: Summarizes assumptions and limitations.
- **`PARAMETERS.md`**: Documents `hsa_savings_balances` and HFP `HSA ctrb` column.

### 2.2 Implementation Location

- **Core**: `src/owlplanner/plan.py` — account type `j=3`
- **Config**: `schema.py` (`hsa_savings_balances`), `plan_bridge.py`
- **Tests**: `tests/test_hsa.py`

---

## 3. Sanity Check: Formulation Details

### 3.1 Contributions Reduce Taxable Income ✓

**Code** (`plan.py` ~1868, 2033, 2225):
```python
rhs -= self.kappa_ijn[i, 3, n]   # taxable income
rhs_pi -= self.kappa_ijn[i, 3, n]   # provisional income (SS taxability)
rhs -= self.kappa_ijn[i, 3, n2]   # MAGI (IRMAA lookback)
```

**Verdict**: HSA contributions correctly reduce ordinary income, provisional income, and MAGI. Consistent with IRC §223(a) and documentation.

---

### 3.2 Withdrawals Are Tax-Free ✓

**Code** (`plan.py` ~1828–1829):
```python
# HSA: qualified medical withdrawals are tax-free (simplified model)
row.addElem(self.vm["w"].idx(i, 3, n), -1)
```

Withdrawals enter cash flow at full value (no tax coefficient). Documentation states all HSA withdrawals are treated as qualified.

**Verdict**: Correct for the intended use (Medicare premiums, medical costs). Limitation: pre-65 non-qualified penalty (20%) and post-65 non-qualified taxation not modeled — acknowledged in docs.

---

### 3.3 Balance Carryover ✓

**Code** (`plan.py` `_add_account_balance_carryover`, ~1773):
```python
rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
row.addElem(self.vm["b"].idx(i, j, n + 1), 1)
row.addElem(self.vm["b"].idx(i, j, n), -fac1 * Tau1_ijn[i, j, n])
row.addElem(self.vm["w"].idx(i, j, n), fac1 * Tau1_ijn[i, j, n])
```

HSA (j=3) follows the same dynamics as other accounts: `b[n+1] = (b[n] - w[n]) * (1+τ) + κ * (1+τ/2)`.

**Verdict**: Correct. Growth is tax-free; withdrawals reduce balance.

---

### 3.4 Contributions Stop at Medicare ✓ (when setHSA is used)

**Code** (`plan.py` ~968, 1187–1190):
```python
n_hsa = self.yobs[i] + ages[i] - thisyear
self.n_hsa_i[i] = min(max(0, n_hsa), self.N_n)
# In setContributions:
if n_stop < h:
    self.kappa_ijn[i, 3, n_stop:h] = 0.0
```

Year index `n_hsa` is the first year the person reaches Medicare age. Contributions are zeroed from that year onward.

**Verdict**: When `setHSA()` is called, contributions are correctly stopped in the Medicare year. Conservative vs. IRS (entire year zeroed; IRS allows proration).

---

### 3.5 Medicare Cutoff Year Formula

**Formula**: `n_hsa = yobs[i] + medicare_ages[i] - thisyear`

For `medicare_ages=65`: at year index `n_hsa`, `thisyear + n_hsa - yobs[i] = 65`. That is the first year the person turns 65.

**Verdict**: Correct. Zeroing from that year onward matches IRS rule that Medicare enrollment disqualifies contributions.

---

### 3.6 Estate/Bequest Treatment ✓

**Code** (`plan.py` ~1721, 2277, 3085, 3140; `export.py` ~226, 261):
- Objective: `b[i,3,N_n]` enters with coefficient `(1 - nu)`.
- Non-spouse heirs: HSA discounted by `(1 - nu)` (same as tax-deferred).

**Verdict**: Consistent with IRC §223(f)(8)(B): non-spouse beneficiary includes full balance in ordinary income. Spouse inherits intact (`phi_j[3]=1` by default).

---

### 3.7 Spouse Transfer at Death ✓

**Code**: `phi_j[3]` defaults to 1.0. At first death, surviving spouse receives full HSA per beneficiary fractions.

**Verdict**: Correct per IRC §223(f)(8)(B).

---

## 4. Documented Limitations vs Implementation

| Limitation | Implementation | Notes |
|------------|----------------|-------|
| All withdrawals qualified | Yes | All `w[i,3,n]` tax-free |
| IRS contribution limits not enforced | Yes | No LP constraint; user must comply |
| Catch-up $1,000 (55+) not auto-applied | Yes | User adds manually in HFP |
| Mid-year Medicare: year zeroed | Yes | Conservative; IRS allows proration |
| HDHP eligibility not verified | N/A | Assumed |
| Pre-65 non-qualified penalty not modeled | N/A | All withdrawals assumed qualified |

---

## 5. Potential Issues

### 5.1 `n_hsa_i` Not Set When Loading from TOML

**Finding**: `plan_bridge._apply_assets_to_plan` calls `setAccountBalances(hsa=...)` but **never** calls `setHSA()`. `n_hsa_i` therefore remains at its default `N_n` (contributions never stop). The zeroing in `setContributions` only applies when `n_stop < h`; with `n_stop = N_n`, it never triggers.

**Impact**: For cases loaded from TOML with `hsa_savings_balances` and an HFP file that includes HSA contributions, contributions would continue past Medicare enrollment, contrary to IRS rules and docs.

**Fix applied** (two layers):
1. **plan_bridge**: `_apply_assets_to_plan` sets `n_hsa_i` directly after `setAccountBalances` (cannot call `setHSA` here—it would re-apply unit scaling and corrupt balances).
2. **plan.setContributions**: If `n_hsa_i[i]` is still at its default (`N_n`), it is initialized from `yobs` and age 65 before zeroing. This catches programmatic plans that bypass config (e.g. `setAccountBalances` + `readHFP` without going through TOML).

---

### 5.2 Last-Five-Years Block for HSA

**Finding**: In `setContributions`, the “last 5 years” block `kappa_ijn[i, 3, -5:]` is filled from `timeLists["HSA ctrb"][:5]` (pre-plan years) and is **not** zeroed for Medicare. Those indices (N_n to N_n+4) are not used in the LP (taxable income, MAGI, balance carryover use `n in range(N_n)`).

**Verdict**: Low priority. The -5 block is historical; Medicare zeroing is not needed there. No change required unless that block is reused for other logic.

---

### 5.3 Contribution Limits

**IRS 2026**: $4,400 self-only, $8,750 family, +$1,000 catch-up at 55+.

**Implementation**: No enforcement. User is responsible.

**Verdict**: Matches documented design. Optional future enhancement: warn or cap if HFP values exceed limits.

---

## 6. Tests

`tests/test_hsa.py` covers:

- Zero vs non-zero HSA balances
- HSA increasing spending
- HSA contribution deduction from taxable income
- Medicare cutoff via `setHSA`
- Bequest discount
- Spouse transfer (`phi_j[3]=1`)

---

## 7. Summary

| Aspect | Status |
|--------|--------|
| Contributions reduce income, PI, MAGI | ✓ |
| Withdrawals tax-free (qualified) | ✓ |
| Balance dynamics | ✓ |
| Medicare cutoff (when setHSA used) | ✓ |
| Estate/heirs treatment | ✓ |
| Spouse inheritance | ✓ |
| Config loading sets `n_hsa_i` | ✓ (fixed in plan_bridge) |

**Change made**: (1) `plan_bridge` sets `n_hsa_i` directly after `setAccountBalances`. (2) `setContributions` initializes `n_hsa_i` from age 65 when still at default, covering programmatic plans.
