diff --git a/frontend/src/components/ScenarioDetail.tsx b/frontend/src/components/ScenarioDetail.tsx
index 75bbf038..5c3d9be2 100644
--- a/frontend/src/components/ScenarioDetail.tsx
+++ b/frontend/src/components/ScenarioDetail.tsx
@@ -685,11 +685,41 @@ export function YearDetailPanel({
Withdrawals by account
-
- Taxable: {formatFull(year.withdrawals.taxable, currency)}
- {' \u00B7 '}Tax-deferred: {formatFull(year.withdrawals.tax_deferred, currency)}
- {' \u00B7 '}Tax-free: {formatFull(year.withdrawals.tax_free, currency)}
-
+ {(() => {
+ // The legacy ``taxable`` total already folds yield spent directly
+ // (without selling shares) into the taxable-account withdrawal.
+ // Expose the additive split so ``investment_yield`` and ``taxable``
+ // visibly reconcile against expenses. Default missing fields to 0
+ // for backward compatibility with older stored snapshots.
+ const taxablePrincipal = year.withdrawals.taxable_principal ?? 0;
+ const taxableYieldConsumed =
+ year.withdrawals.taxable_yield_consumed ?? 0;
+ const showReconciliation = taxableYieldConsumed > 0;
+ return (
+ <>
+
+ Taxable: {formatFull(
+ showReconciliation ? taxablePrincipal : year.withdrawals.taxable,
+ currency,
+ )}
+ {' \u00B7 '}Tax-deferred: {formatFull(year.withdrawals.tax_deferred, currency)}
+ {' \u00B7 '}Tax-free: {formatFull(year.withdrawals.tax_free, currency)}
+
+ {showReconciliation && (
+
+ {formatFull(taxablePrincipal, currency)} principal sold
+ {' + '}
+ {formatFull(taxableYieldConsumed, currency)} yield consumed
+ {' = '}
+ {formatFull(year.withdrawals.taxable, currency)}
+
+ )}
+ >
+ );
+ })()}
{year.capital_gains_realized > 0 && (
diff --git a/simulation_engine/detail.py b/simulation_engine/detail.py
index 8304c4cc..b7b98687 100644
--- a/simulation_engine/detail.py
+++ b/simulation_engine/detail.py
@@ -146,6 +146,14 @@ class WithdrawalBreakdown:
tax_deferred: float
tax_free: float
total: float
+ # Additive split of the taxable-account figure: ``taxable_principal`` is the
+ # value of taxable-account shares sold to fund spending; ``taxable_yield_consumed``
+ # is the portion of the year's investment yield spent directly without
+ # selling shares. ``taxable_principal + taxable_yield_consumed == taxable``.
+ # Optional with 0.0 defaults so legacy fixtures / stored artifacts that
+ # predate these fields still validate.
+ taxable_principal: float = 0.0
+ taxable_yield_consumed: float = 0.0
@dataclass
@@ -1793,6 +1801,8 @@ def replay_scenario_with_accounting(
tax_deferred=actual_withdrawals["tax_deferred"] / cumulative_inflation,
tax_free=actual_withdrawals["tax_free"] / cumulative_inflation,
total=(total_principal_withdrawn + yield_spent_nominal) / cumulative_inflation,
+ taxable_principal=actual_withdrawals["taxable"] / cumulative_inflation,
+ taxable_yield_consumed=yield_spent_nominal / cumulative_inflation,
)
englobamento_detail = getattr(annual_tax_result, "portugal_englobamento", None)
diff --git a/tests/test_scenario_detail.py b/tests/test_scenario_detail.py
index bfe54274..2828e850 100644
--- a/tests/test_scenario_detail.py
+++ b/tests/test_scenario_detail.py
@@ -564,6 +564,37 @@ def test_year_detail_fields_are_internally_consistent():
assert w.total == pytest.approx(w.taxable + w.tax_deferred + w.tax_free, abs=0.01)
+def test_taxable_withdrawal_splits_into_principal_and_yield_consumed():
+ """The legacy ``taxable`` field already folds yield spent directly into the
+ displayed taxable-account withdrawal. ``taxable_principal +
+ taxable_yield_consumed`` must equal ``taxable`` row-by-row, and at least one
+ retirement year must show a non-zero yield-consumed share so the
+ Scenario Detail panel can reconcile investment yield against expenses."""
+ sc = _make_config(
+ horizon=15,
+ n_scenarios=1,
+ approximate_yield_rate=0.04, # Force non-trivial yield so spending hits it.
+ baseline_annual_expenses=60_000.0,
+ )
+ _, _, detail = _run_and_replay(sc)
+
+ saw_nonzero_yield_consumed = False
+ for year_detail in detail.years:
+ w = year_detail.withdrawals
+ assert w.taxable == pytest.approx(
+ w.taxable_principal + w.taxable_yield_consumed, abs=0.01
+ )
+ assert w.taxable_principal >= -0.01
+ assert w.taxable_yield_consumed >= -0.01
+ if w.taxable_yield_consumed > 0.01:
+ saw_nonzero_yield_consumed = True
+
+ assert saw_nonzero_yield_consumed, (
+ "Expected at least one year to consume taxable-account yield directly; "
+ "without yield-consumed > 0 the new field is untested."
+ )
+
+
def test_replay_surfaces_dual_tax_breakdown_for_us_citizen_abroad():
sc = _make_config(
country="Portugal",