diff --git a/src/InfoPopup.test.tsx b/src/InfoPopup.test.tsx
new file mode 100644
index 0000000..1edb88d
--- /dev/null
+++ b/src/InfoPopup.test.tsx
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { InfoText } from './InfoPopup';
+
+describe('InfoText breakdown', () => {
+ it('renders each formula term with its value when a breakdown is provided', () => {
+ render(
+
+ Income to Fully Utilize
+
+ );
+
+ // Open the popup.
+ fireEvent.click(screen.getByText('Income to Fully Utilize'));
+
+ // Headline value still shows.
+ expect(screen.getByText('$1,934,500')).toBeInTheDocument();
+
+ // Every component term and value is surfaced.
+ expect(screen.getByText('§461(l) Deduction')).toBeInTheDocument();
+ expect(screen.getByText('$512,000')).toBeInTheDocument();
+ expect(screen.getByText('Start-of-Year NOL ($1,000,000) ÷ 80%')).toBeInTheDocument();
+ expect(screen.getByText('$1,250,000')).toBeInTheDocument();
+
+ // The netted-out term renders as a subtraction.
+ expect(screen.getByText('−$24,000')).toBeInTheDocument();
+ });
+
+ it('omits the breakdown list when none is provided', () => {
+ render(
+
+ Income to Fully Utilize
+
+ );
+ fireEvent.click(screen.getByText('Income to Fully Utilize'));
+ expect(screen.getByText('$100,000')).toBeInTheDocument();
+ expect(document.querySelector('.field-breakdown')).toBeNull();
+ });
+});
diff --git a/src/InfoPopup.tsx b/src/InfoPopup.tsx
index 69b6e27..9240bb1 100644
--- a/src/InfoPopup.tsx
+++ b/src/InfoPopup.tsx
@@ -47,35 +47,79 @@ export function InfoPopup({ title, children }: InfoPopupProps) {
);
}
+// A single labeled term of a formula, shown as a "Your value" sub-breakdown.
+// `negative` renders the term as a subtraction (the netted-out piece).
+export interface PopupBreakdownItem {
+ label: string;
+ value: string;
+ negative?: boolean;
+}
+
+// Shared body for the field-level popups (FieldInfoPopup + InfoText) so the
+// definition / formula / value / breakdown / impact layout stays in one place.
+interface FieldPopupBodyContent {
+ definition: string;
+ formula?: string;
+ impact: string;
+}
+
+function FieldPopupBody({
+ content,
+ currentValue,
+ breakdown,
+}: {
+ content: FieldPopupBodyContent;
+ currentValue?: string;
+ breakdown?: PopupBreakdownItem[];
+}) {
+ return (
+
+
{content.definition}
+ {content.formula && (
+
+ Formula:
+ {content.formula}
+
+ )}
+ {currentValue && (
+
+ Your value: {currentValue}
+
+ )}
+ {breakdown && breakdown.length > 0 && (
+
+ {breakdown.map(item => (
+
+
- {item.label}
+ -
+ {item.negative ? '−' : ''}
+ {item.value}
+
+
+ ))}
+
+ )}
+
+ Impact: {content.impact}
+
+
+ );
+}
+
// Field-level info popup - shows definition, formula, and impact
interface FieldInfoPopupProps {
contentKey: string;
currentValue?: string;
+ breakdown?: PopupBreakdownItem[];
}
-export function FieldInfoPopup({ contentKey, currentValue }: FieldInfoPopupProps) {
+export function FieldInfoPopup({ contentKey, currentValue, breakdown }: FieldInfoPopupProps) {
const content = getPopupContent(contentKey);
if (!content) return null;
return (
-
-
{content.definition}
- {content.formula && (
-
- Formula:
- {content.formula}
-
- )}
- {currentValue && (
-
- Your value: {currentValue}
-
- )}
-
- Impact: {content.impact}
-
-
+
);
}
@@ -85,9 +129,10 @@ interface InfoTextProps {
contentKey: string;
children: React.ReactNode;
currentValue?: string;
+ breakdown?: PopupBreakdownItem[];
}
-export function InfoText({ contentKey, children, currentValue }: InfoTextProps) {
+export function InfoText({ contentKey, children, currentValue, breakdown }: InfoTextProps) {
const [isOpen, setIsOpen] = useState(false);
const content = getPopupContent(contentKey);
@@ -130,23 +175,11 @@ export function InfoText({ contentKey, children, currentValue }: InfoTextProps)
-
-
{content.definition}
- {content.formula && (
-
- Formula:
- {content.formula}
-
- )}
- {currentValue && (
-
- Your value: {currentValue}
-
- )}
-
- Impact: {content.impact}
-
-
+
,
diff --git a/src/calculations.test.ts b/src/calculations.test.ts
index 43be003..ce180c0 100644
--- a/src/calculations.test.ts
+++ b/src/calculations.test.ts
@@ -144,6 +144,22 @@ describe('incomeRequiredForFullUtilization', () => {
const below = calculateWithOverrides(inputs, DEFAULT_SETTINGS, mkOverride(required * 0.5));
expect(below.years[1].nolUsedThisYear).toBeLessThan(startNolY2);
});
+
+ it('exposes formula components that reconcile to the headline figure', () => {
+ // Use a case that exercises every term: a large core builds NOL, so Year 2
+ // has a nonzero §461(l) deduction, NOL gross-up, and netted gains.
+ const result = calculate(createInputs({ collateralAmount: 10000000, annualIncome: 200000 }));
+ for (const year of result.years) {
+ const c = year.incomeRequiredComponents;
+ const reconstructed = Math.max(
+ 0,
+ c.section461Deduction + c.capitalLossUsed + c.nolGrossUp - c.netTaxableGains
+ );
+ expect(reconstructed).toBeCloseTo(year.incomeRequiredForFullUtilization, 2);
+ // The gross-up is the start-of-year NOL divided by the 80% offset limit.
+ expect(c.nolGrossUp).toBeCloseTo(c.nolLimit > 0 ? c.startOfYearNol / c.nolLimit : 0, 2);
+ }
+ });
});
describe('redeployQfafProceeds', () => {
diff --git a/src/calculations/core.ts b/src/calculations/core.ts
index e760fca..3ab7b26 100644
--- a/src/calculations/core.ts
+++ b/src/calculations/core.ts
@@ -883,16 +883,30 @@ export function calculateYear(
// start-of-year NOL balance. Capital-gain income already contributes, so
// it is netted out; nolCarryforward here is the start-of-year balance.
const nolLimitForRequired = settings.nolOffsetLimit ?? NOL_OFFSET_PERCENTAGE;
+ // Decomposition of the income-required formula, surfaced in the metric's info
+ // popup so advisors can see where the planning target comes from. These are
+ // the literal terms of the formula below — do not recompute them elsewhere.
+ const incomeRequiredComponents = {
+ // §461(l) ordinary deduction that must be absorbed by income this year
+ section461Deduction: safeNumber(allowedOrdinaryLoss),
+ // Capital loss applied against ordinary income this year (≤ $3K / $1.5K MFS)
+ capitalLossUsed: safeNumber(capitalLossUsedAgainstIncome),
+ // Start-of-year NOL balance the 80% limit must consume
+ startOfYearNol: safeNumber(nolCarryforward),
+ // The 80% (or overridden) NOL offset limit applied to the gross-up
+ nolLimit: nolLimitForRequired,
+ // Start-of-year NOL grossed up by the 80% limit (income needed to use it all)
+ nolGrossUp: safeNumber(nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0),
+ // Capital-gain income the strategy already generates, netted out of the target
+ netTaxableGains: safeNumber(taxableSt + taxableLt + eventTaxableSt + eventTaxableLt),
+ };
const incomeRequiredForFullUtilization = safeNumber(
Math.max(
0,
- allowedOrdinaryLoss +
- capitalLossUsedAgainstIncome +
- (nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0) -
- taxableSt -
- taxableLt -
- eventTaxableSt -
- eventTaxableLt
+ incomeRequiredComponents.section461Deduction +
+ incomeRequiredComponents.capitalLossUsed +
+ incomeRequiredComponents.nolGrossUp -
+ incomeRequiredComponents.netTaxableGains
)
);
@@ -936,6 +950,7 @@ export function calculateYear(
incomeOffsetAmount,
maxIncomeOffsetCapacity,
incomeRequiredForFullUtilization,
+ incomeRequiredComponents,
gainEventAmount: safeNumber(gainEventAmount),
gainEventTax,
gainEventTaxWithoutStrategy,
diff --git a/src/calculations/sensitivity.ts b/src/calculations/sensitivity.ts
index 55e6d69..75007f7 100644
--- a/src/calculations/sensitivity.ts
+++ b/src/calculations/sensitivity.ts
@@ -623,16 +623,24 @@ function calculateYearWithSensitivity(
usableOrdinaryLoss + nolUsed + capitalLossUsedAgainstIncome
);
- // Minimum W-2 income to fully utilize this year's shelter (see core.ts)
+ // Minimum W-2 income to fully utilize this year's shelter (see core.ts).
+ // The grid has no gain events, so the netted-gains term is ST/LT only.
const nolLimitForRequired = settings.nolOffsetLimit ?? NOL_OFFSET_PERCENTAGE;
+ const incomeRequiredComponents = {
+ section461Deduction: safeNumber(allowedOrdinaryLoss),
+ capitalLossUsed: safeNumber(capitalLossUsedAgainstIncome),
+ startOfYearNol: safeNumber(nolCarryforward),
+ nolLimit: nolLimitForRequired,
+ nolGrossUp: safeNumber(nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0),
+ netTaxableGains: safeNumber(taxableSt + taxableLt),
+ };
const incomeRequiredForFullUtilization = safeNumber(
Math.max(
0,
- allowedOrdinaryLoss +
- capitalLossUsedAgainstIncome +
- (nolLimitForRequired > 0 ? nolCarryforward / nolLimitForRequired : 0) -
- taxableSt -
- taxableLt
+ incomeRequiredComponents.section461Deduction +
+ incomeRequiredComponents.capitalLossUsed +
+ incomeRequiredComponents.nolGrossUp -
+ incomeRequiredComponents.netTaxableGains
)
);
@@ -671,6 +679,7 @@ function calculateYearWithSensitivity(
incomeOffsetAmount,
maxIncomeOffsetCapacity,
incomeRequiredForFullUtilization,
+ incomeRequiredComponents,
gainEventAmount: 0,
gainEventTax: 0,
gainEventTaxWithoutStrategy: 0,
diff --git a/src/index.css b/src/index.css
index 9081d23..8beb1c4 100644
--- a/src/index.css
+++ b/src/index.css
@@ -3878,6 +3878,44 @@ tfoot td {
margin-right: 0.5rem;
}
+/* Per-term breakdown of the "Your value" figure (e.g. the income-required
+ formula split into its §461(l) / $3K / NOL / netted-gains components). */
+.field-popup .field-breakdown {
+ margin: 0 0 0.875rem 0;
+ padding: 0.5rem 0.875rem;
+ background: var(--bg);
+ border-radius: 6px;
+}
+
+.field-popup .field-breakdown-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 1rem;
+ padding: 0.25rem 0;
+}
+
+.field-popup .field-breakdown-row + .field-breakdown-row {
+ border-top: 1px solid var(--border);
+}
+
+.field-popup .field-breakdown-row dt {
+ color: var(--text-light);
+ font-size: 0.8rem;
+}
+
+.field-popup .field-breakdown-row dd {
+ margin: 0;
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.field-popup .field-breakdown-row dd.negative {
+ color: var(--danger, #dc2626);
+}
+
.field-popup .field-impact {
margin: 0;
padding: 0.75rem 1rem;
diff --git a/src/lossBreakdown.test.ts b/src/lossBreakdown.test.ts
index 111e5c2..892e700 100644
--- a/src/lossBreakdown.test.ts
+++ b/src/lossBreakdown.test.ts
@@ -41,6 +41,14 @@ function makeYear(overrides: Partial = {}): YearResult {
qfafCashReturned: 0,
financingCostPaid: 0,
incomeRequiredForFullUtilization: 0,
+ incomeRequiredComponents: {
+ section461Deduction: 0,
+ capitalLossUsed: 0,
+ startOfYearNol: 0,
+ nolLimit: 0.8,
+ nolGrossUp: 0,
+ netTaxableGains: 0,
+ },
gainEventAmount: 0,
gainEventTax: 0,
gainEventTaxWithoutStrategy: 0,
diff --git a/src/taxData.ts b/src/taxData.ts
index 987d0e3..871ae6b 100644
--- a/src/taxData.ts
+++ b/src/taxData.ts
@@ -3,20 +3,11 @@ import { CalculatorInputs } from './types';
// All 50 US States + DC with 2026 top marginal income tax rates
// Sources: Tax Foundation, state tax authorities
// Note: Rates shown are top marginal rates for high-income earners
+// Display order: alphabetical by name, with the custom "Other" entry pinned
+// last. States with a 0 rate levy no income tax (e.g. FL, TX, WA).
export const STATES = [
- // No income tax states
- { code: 'AK', name: 'Alaska', rate: 0 },
- { code: 'FL', name: 'Florida', rate: 0 },
- { code: 'NV', name: 'Nevada', rate: 0 },
- { code: 'NH', name: 'New Hampshire', rate: 0 }, // No tax on earned income (only interest/dividends, phasing out)
- { code: 'SD', name: 'South Dakota', rate: 0 },
- { code: 'TN', name: 'Tennessee', rate: 0 },
- { code: 'TX', name: 'Texas', rate: 0 },
- { code: 'WA', name: 'Washington', rate: 0 }, // No income tax (has capital gains tax of 7% on gains > $270k)
- { code: 'WY', name: 'Wyoming', rate: 0 },
-
- // States with income tax (alphabetical)
{ code: 'AL', name: 'Alabama', rate: 0.05 },
+ { code: 'AK', name: 'Alaska', rate: 0 },
{ code: 'AZ', name: 'Arizona', rate: 0.025 }, // Flat rate as of 2023
{ code: 'AR', name: 'Arkansas', rate: 0.039 }, // Reduced in 2026
{ code: 'CA', name: 'California', rate: 0.133 }, // Highest in nation (12.3% + 1% mental health surtax >$1M)
@@ -24,6 +15,7 @@ export const STATES = [
{ code: 'CT', name: 'Connecticut', rate: 0.0699 },
{ code: 'DE', name: 'Delaware', rate: 0.066 },
{ code: 'DC', name: 'District of Columbia', rate: 0.1075 },
+ { code: 'FL', name: 'Florida', rate: 0 },
{ code: 'GA', name: 'Georgia', rate: 0.0519 }, // Reduced from 5.39% mid-2025
{ code: 'HI', name: 'Hawaii', rate: 0.11 },
{ code: 'ID', name: 'Idaho', rate: 0.058 }, // Flat rate
@@ -42,6 +34,8 @@ export const STATES = [
{ code: 'MO', name: 'Missouri', rate: 0.048 }, // Reduced from 4.95%
{ code: 'MT', name: 'Montana', rate: 0.059 }, // Reduced from 6.75%
{ code: 'NE', name: 'Nebraska', rate: 0.0455 }, // Reduced from 5.2% in 2026
+ { code: 'NV', name: 'Nevada', rate: 0 },
+ { code: 'NH', name: 'New Hampshire', rate: 0 }, // No tax on earned income (only interest/dividends, phasing out)
{ code: 'NJ', name: 'New Jersey', rate: 0.1075 },
{ code: 'NM', name: 'New Mexico', rate: 0.059 },
{ code: 'NY', name: 'New York', rate: 0.109 }, // Top rate with temporary high-earner surtax extended
@@ -53,11 +47,16 @@ export const STATES = [
{ code: 'PA', name: 'Pennsylvania', rate: 0.0307 }, // Flat rate
{ code: 'RI', name: 'Rhode Island', rate: 0.0599 },
{ code: 'SC', name: 'South Carolina', rate: 0.064 }, // Being phased down
+ { code: 'SD', name: 'South Dakota', rate: 0 },
+ { code: 'TN', name: 'Tennessee', rate: 0 },
+ { code: 'TX', name: 'Texas', rate: 0 },
{ code: 'UT', name: 'Utah', rate: 0.0465 }, // Flat rate
{ code: 'VT', name: 'Vermont', rate: 0.0875 },
{ code: 'VA', name: 'Virginia', rate: 0.0575 },
+ { code: 'WA', name: 'Washington', rate: 0 }, // No income tax (has capital gains tax of 7% on gains > $270k)
{ code: 'WV', name: 'West Virginia', rate: 0.047 }, // Reduced from 5.12%
{ code: 'WI', name: 'Wisconsin', rate: 0.0765 },
+ { code: 'WY', name: 'Wyoming', rate: 0 },
// Custom entry option
{ code: 'OTHER', name: 'Other (enter rate)', rate: 0 },
diff --git a/src/types.ts b/src/types.ts
index a08dae3..f7150e5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -196,6 +196,21 @@ export interface YearResult {
// deduction AND lets the 80% limit consume the entire start-of-year NOL
// balance (planning target for bonuses/option exercises)
incomeRequiredForFullUtilization: number;
+ /**
+ * Decomposition of `incomeRequiredForFullUtilization` into the literal terms
+ * of its formula (§461(l) deduction + $3K used + start-of-year NOL ÷ limit −
+ * net taxable gains). Surfaced in the metric's info popup so the planning
+ * target is auditable. Not a separate analysis — these are the same values
+ * core.ts nets into the headline figure.
+ */
+ incomeRequiredComponents: {
+ section461Deduction: number;
+ capitalLossUsed: number;
+ startOfYearNol: number;
+ nolLimit: number;
+ nolGrossUp: number;
+ netTaxableGains: number;
+ };
// Tax benefit breakdown (gross benefits and costs)
ordinaryLossBenefit: number; // usableOrdinaryLoss × combined ST rate
diff --git a/src/workspace/WorkspaceTab.tsx b/src/workspace/WorkspaceTab.tsx
index 3dddda4..951d716 100644
--- a/src/workspace/WorkspaceTab.tsx
+++ b/src/workspace/WorkspaceTab.tsx
@@ -837,6 +837,33 @@ export function WorkspaceTab() {
{ amount: 0, year: 1 }
);
const incomeReqYears = results.years.filter(y => y.incomeRequiredForFullUtilization > 0.5);
+ // Decompose the peak income-required figure into its formula terms for the
+ // info popup, straight from the engine (no parallel calc) so advisors can see
+ // where the planning target comes from.
+ const peakIncomeReqYear = results.years.find(y => y.year === peakIncomeReq.year);
+ const incomeReqBreakdown = peakIncomeReqYear
+ ? [
+ {
+ label: '§461(l) Deduction',
+ value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.section461Deduction),
+ },
+ {
+ label: '$3K Used',
+ value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.capitalLossUsed),
+ },
+ {
+ label: `Start-of-Year NOL (${formatCurrency(
+ peakIncomeReqYear.incomeRequiredComponents.startOfYearNol
+ )}) ÷ ${formatPercent(peakIncomeReqYear.incomeRequiredComponents.nolLimit, 0)}`,
+ value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.nolGrossUp),
+ },
+ {
+ label: 'Net Taxable ST/LT Gains',
+ value: formatCurrency(peakIncomeReqYear.incomeRequiredComponents.netTaxableGains),
+ negative: true,
+ },
+ ]
+ : undefined;
const year1 = results.years[0]?.taxSavings ?? 0;
const year2 = results.years[1]?.taxSavings ?? 0;
const projectionYears = settings.projectionYears ?? 10;
@@ -2079,6 +2106,7 @@ export function WorkspaceTab() {
Income to Fully Utilize