---
id: taxable-withdrawal-yield-breakdown
status: task
depends_on: []
description: Split the taxable-account withdrawal into yield-consumed vs principal-sold so the UI/CSV no longer double-count the investment yield
---

## Goal

The Scenario Detail panel today shows two lines that visually do not reconcile:

- **Investment yield**: e.g. `$95,205 from taxable account`
- **Withdrawals by account → Taxable**: e.g. `$200,000`

When expenses are $200K, a natural read is "$95K yield + $200K withdrawal = $295K", but the
engine actually folds the yield-spent portion *into* the displayed withdrawal
(`simulation_engine/detail.py:1791-1796` defines `WithdrawalBreakdown.taxable =
actual_withdrawals["taxable"] + yield_spent_nominal`). That is, the $200K already includes
the $95K of yield being consumed; only ~$105K of principal was actually sold.

Fix it **additively** — add new fields to `WithdrawalBreakdown` for `taxable_principal` and
`taxable_yield_consumed`, plumb them through the API schema and frontend types, and update
the Scenario Detail panel and CSV export to expose the breakdown. Do **not** redefine the
existing `taxable` / `total` fields (they have downstream consumers including the Cash Flow
Sankey and existing tests). The legacy fields stay as the gross figure for backward
compatibility.

After the change, a user reading the panel should be able to verify
`investment_yield + taxable_principal + tax_deferred + tax_free + income ≈ expenses` without
mental gymnastics.

## Affected Files

**Engine (producer):**
- `simulation_engine/detail.py` — extend `WithdrawalBreakdown` dataclass (around line 144)
  with `taxable_principal: float` and `taxable_yield_consumed: float`. Populate them in the
  `WithdrawalBreakdown(...)` construction at lines 1791-1796 (`taxable_principal =
  actual_withdrawals["taxable"] / cumulative_inflation`, `taxable_yield_consumed =
  yield_spent_nominal / cumulative_inflation`). Also populate them as `0.0` in the
  `zero_withdrawals` instance around line 2140 and in any other `WithdrawalBreakdown(...)`
  constructors.

**API schema + service:**
- `api/schemas.py` — add the two new fields to the Pydantic `WithdrawalBreakdown` schema
  (search for the existing `WithdrawalBreakdown` model, near `withdrawals.taxable`
  references; defaults to `0.0` so older clients don't break).
- `api/services.py:668` — populate the new fields from `yd.withdrawals.taxable_principal`
  and `yd.withdrawals.taxable_yield_consumed`.

**CSV export:**
- `api/ledger_export.py` — add two new CSV columns to `LEDGER_CSV_HEADERS` (line 10–~25):
  `taxable_account_principal` and `taxable_account_yield_consumed`. Append them at the end
  so existing column order is preserved. Populate them in `ledger_row_values` (line 51+)
  alongside the existing `taxable_account_withdrawal` entry. **Leave
  `taxable_account_withdrawal` unchanged** for backward compatibility.

**Frontend:**
- `frontend/src/api/types.ts` — extend the TypeScript `WithdrawalBreakdown` interface with
  the two new optional-but-default-0 fields.
- `frontend/src/components/ScenarioDetail.tsx` — at line 686-693 ("Withdrawals by account"
  section), change the Taxable line to render the principal-only figure
  (`year.withdrawals.taxable_principal`) and add a small muted sub-line showing the
  reconciliation, e.g.
  `Taxable: $105,000 (principal sold) + $95,205 (yield consumed) = $200,205`. Tax-deferred
  and tax-free lines stay as-is.
  - Choose phrasing carefully — see the Regulatory Framing rules in AGENTS.md
    (calculator, not advice). The wording must be neutral/descriptive.
- `frontend/src/utils/ledgerCsv.ts` — mirror the CSV column additions from
  `api/ledger_export.py`. Both files must stay in sync (there are existing tests for this
  in `frontend/src/utils/ledgerCsv.test.ts`).

**Tests:**
- Add a focused unit test in `tests/test_simulation_core.py` (or `tests/test_scenario_detail.py`)
  that exercises a year with non-zero yield and asserts
  `withdrawals.taxable_principal + withdrawals.taxable_yield_consumed ==
  withdrawals.taxable` (the additive invariant).
- Existing tests at `tests/test_simulation_core.py:494, 600` should keep passing without
  modification (those scenarios likely have zero yield; verify rather than assume).
- Update `tests/test_export.py` (line 1065+) and any other test that constructs a
  `WithdrawalBreakdown(...)` literal to include the new fields (defaulting to 0.0 is fine).
- If `frontend/src/utils/ledgerCsv.test.ts` snapshots CSV headers, update the snapshot to
  include the two new columns at the end.

## Verification

1. `make test` — full Python suite, including the new invariant test.
2. `make lint` — ruff + tsc + ESLint must all pass.
3. Manual visual check (via Playwright MCP if available, otherwise `make demo`):
   - Open a scenario with positive investment yield and non-zero expenses (the screenshot
     example uses `Pay-off mortgage` under `Seattle Now`, year 2027).
   - Expand a year row in the year-by-year ledger.
   - Verify the new Taxable line shows yield/principal split that adds up to the prior
     gross figure.
   - Verify `investment_yield + taxable_principal + tax_deferred + tax_free ≈ expenses`
     within rounding (after adding any non-withdrawal income lines).
4. CSV sanity check: download a ledger CSV (or inspect via the existing export test) and
   confirm the two new columns appear at the end of `LEDGER_CSV_HEADERS`, and that
   `taxable_account_withdrawal == taxable_account_principal + taxable_account_yield_consumed`
   row-by-row.

## Danger Zones

- **Do not redefine existing fields.** Both `WithdrawalBreakdown.taxable` and `.total` are
  read by the Cash Flow Sankey (`frontend/src/components/CashFlowSankey.tsx:84-88`) and by
  CSV export (`api/ledger_export.py:77-79`). Changing their semantics risks visual and
  data-export regressions. Strictly add new fields.
- **Vectorized vs. scalar engines.** Only `simulation_engine/detail.py` builds
  `WithdrawalBreakdown` instances. The vectorized path in `simulation_engine/core.py`
  computes `yield_spent_nominal` (line 2693) and `principal_required_nominal` (line 2704)
  but doesn't assemble per-year breakdowns. No vectorized-path changes are required for
  this task; if you discover a vectorized assembly point, mirror the additive pattern.
- **Construction sites.** `WithdrawalBreakdown(...)` is constructed in multiple places
  (e.g. `simulation_engine/detail.py:2140` for the `zero_withdrawals` case, and in test
  fixtures). Grep for `WithdrawalBreakdown(` and update every constructor — missing one
  will raise `TypeError` if the new fields are required, or silently default to 0 if
  optional. Prefer making them optional with `= 0.0` defaults to minimise the blast radius
  in tests.
- **API schema backward compat.** The Pydantic schema additions must have defaults of
  `0.0` so older clients (or stored result artifacts from prior runs) don't fail
  validation. Likewise the TypeScript interface should treat the fields as optional or
  default to 0.
- **Regulatory framing.** New copy in the ScenarioDetail panel must follow the
  calculator-not-advisor rules in `AGENTS.md` (no "you should", no recommendations — pure
  descriptive labels).
