diff --git a/changelog.d/dwks-form-4952-election.fixed.md b/changelog.d/dwks-form-4952-election.fixed.md new file mode 100644 index 00000000000..204c68fac76 --- /dev/null +++ b/changelog.d/dwks-form-4952-election.fixed.md @@ -0,0 +1 @@ +Apply the Form 4952 line 4g investment income election in the Schedule D Tax Worksheet (lines 3 to 10) as in net capital gain, take any election above the gain from qualified dividends (26 U.S.C. 1(h)(11)(D)(i)), and count capital gain distributions together with Schedule D gains in worksheet line 9. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks14.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks14.yaml index 2f9bd240d75..d5a2dfe83af 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks14.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks14.yaml @@ -46,8 +46,8 @@ # when neither capital gains worksheet was completed. dwks14: 130_000 -# Synthetic inputs: on a real return worksheet line 10 and net capital gain -# are the same amount. They are set apart here only to show which one each +# Synthetic inputs: on a real return, and in the model for the same inputs, +# worksheet line 10 and net capital gain are the same amount. They are set apart here only to show which one each # excess reads. - name: The worksheet lines read the excess from line 10, not net_capital_gain period: 2025 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_tax_worksheet_form_4952.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_tax_worksheet_form_4952.yaml new file mode 100644 index 00000000000..55bc9ed9711 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_tax_worksheet_form_4952.yaml @@ -0,0 +1,295 @@ +# 2025 Schedule D Tax Worksheet lines 2-10 (Instructions for Schedule D, page +# 15) with a Form 4952 line 4g election: +# 2. qualified dividends +# 3. Form 4952 line 4g (the amount elected to include in investment income) +# 4. Form 4952 line 4e (the smaller of line 4d, the net gain from investment +# property, or the net capital gain from investment property) +# 5. line 3 - line 4, not below zero +# 6. line 2 - line 5, not below zero +# 7. the smaller of Schedule D line 15 or line 16 +# 8. the smaller of line 3 or line 4 +# 9. line 7 - line 8, not below zero +# 10. line 6 + line 9 +# Line 6 is dividend_income_reduced_by_investment_income, line 9 dwks09 and +# line 10 dwks10. Line 10 equals net_capital_gain, which applies 26 U.S.C. +# 1(h)(2) and 1(h)(11)(D)(i) to the same inputs. + +- name: An election within the gain comes off the gain + period: 2025 + input: + people: + person1: + long_term_capital_gains: 300_000 + investment_income_elected_form_4952: 50_000 + tax_units: + tax_unit: + members: [person1] + output: + # Line 2 = 0; line 3 = 50,000; line 4 = min(300,000, 300,000) = 300,000; + # line 5 = 0; line 6 = 0. + dividend_income_reduced_by_investment_income: 0 + # Line 7 = 300,000; line 8 = min(50,000, 300,000) = 50,000; + # line 9 = 300,000 - 50,000. + dwks09: 250_000 + # Line 10 = 0 + 250,000. + dwks10: 250_000 + net_capital_gain: 250_000 + +- name: An election above the gain comes off the qualified dividends + period: 2025 + input: + people: + person1: + long_term_capital_gains: 20_000 + qualified_dividend_income: 30_000 + investment_income_elected_form_4952: 35_000 + tax_units: + tax_unit: + members: [person1] + output: + # Line 4 = 20,000; line 5 = 35,000 - 20,000 = 15,000; + # line 6 = 30,000 - 15,000. + dividend_income_reduced_by_investment_income: 15_000 + # Line 7 = 20,000; line 8 = min(35,000, 20,000) = 20,000; line 9 = 0. + dwks09: 0 + # Line 10 = 15,000 + 0. + dwks10: 15_000 + net_capital_gain: 15_000 + +- name: An election above the gain and the dividends leaves nothing + period: 2025 + input: + people: + person1: + long_term_capital_gains: 10_000 + qualified_dividend_income: 5_000 + investment_income_elected_form_4952: 20_000 + tax_units: + tax_unit: + members: [person1] + output: + # Line 5 = 20,000 - 10,000 = 10,000; line 6 = 5,000 - 10,000, so zero. + dividend_income_reduced_by_investment_income: 0 + # Line 8 = 10,000; line 9 = 10,000 - 10,000 = 0. + dwks09: 0 + dwks10: 0 + net_capital_gain: 0 + +- name: A short-term loss lowers lines 4 and 7 + period: 2025 + input: + people: + person1: + long_term_capital_gains: 40_000 + short_term_capital_gains: -10_000 + qualified_dividend_income: 5_000 + investment_income_elected_form_4952: 32_000 + tax_units: + tax_unit: + members: [person1] + output: + # Form 4952 line 4d = 40,000 - 10,000 = 30,000; net capital gain from + # investment property = 40,000 - 10,000 = 30,000; line 4 = 30,000. + # Line 5 = 32,000 - 30,000 = 2,000; line 6 = 5,000 - 2,000. + dividend_income_reduced_by_investment_income: 3_000 + # Schedule D line 15 = 40,000, line 16 = 30,000; line 7 = 30,000; + # line 8 = min(32,000, 30,000) = 30,000; line 9 = 0. + dwks09: 0 + dwks10: 3_000 + net_capital_gain: 3_000 + +- name: A short-term gain leaves line 4 at the net capital gain + period: 2025 + input: + people: + person1: + long_term_capital_gains: 40_000 + short_term_capital_gains: 15_000 + investment_income_elected_form_4952: 10_000 + tax_units: + tax_unit: + members: [person1] + output: + # Form 4952 line 4d = 55,000; net capital gain from investment property + # = 40,000 (no short-term loss); line 4 = 40,000. Line 5 = 0. + dividend_income_reduced_by_investment_income: 0 + # Schedule D line 15 = 40,000, line 16 = 55,000; line 7 = 40,000; + # line 8 = 10,000; line 9 = 30,000. + dwks09: 30_000 + dwks10: 30_000 + net_capital_gain: 30_000 + +- name: Capital gain distributions without Schedule D can be elected + period: 2025 + input: + people: + person1: + non_sch_d_capital_gains: 8_000 + qualified_dividend_income: 2_000 + investment_income_elected_form_4952: 3_000 + tax_units: + tax_unit: + members: [person1] + output: + # Distributions belong on Schedule D line 13 and are long-term gains for + # Form 4952, so lines 4, 15 and 16 are 8,000. Line 5 = 0; line 6 = 2,000. + dividend_income_reduced_by_investment_income: 2_000 + # Line 7 = 8,000; line 8 = 3,000; line 9 = 5,000. + dwks09: 5_000 + dwks10: 7_000 + net_capital_gain: 7_000 + +- name: Capital gain distributions net against a long-term loss + period: 2025 + input: + people: + person1: + long_term_capital_gains: -5_000 + non_sch_d_capital_gains: 3_000 + tax_units: + tax_unit: + members: [person1] + output: + # Schedule D line 15 = -5,000 + 3,000 = -2,000 = line 16; line 7 = + # -2,000; line 9 = 0. No net capital gain under 26 U.S.C. 1222(11). + dwks09: 0 + dwks10: 0 + net_capital_gain: 0 + +- name: Distributions and Schedule D gains both count + period: 2025 + input: + people: + person1: + long_term_capital_gains: 12_000 + non_sch_d_capital_gains: 3_000 + qualified_dividend_income: 1_000 + tax_units: + tax_unit: + members: [person1] + output: + # Schedule D line 15 = 12,000 + 3,000 = 15,000 = line 16; line 9 = + # 15,000; line 10 = 1,000 + 15,000. + dwks09: 15_000 + dwks10: 16_000 + net_capital_gain: 16_000 + +- name: Without an election line 10 is the gain plus the dividends + period: 2025 + input: + people: + person1: + long_term_capital_gains: 10_000 + short_term_capital_gains: -1_000 + qualified_dividend_income: 2_000 + tax_units: + tax_unit: + members: [person1] + output: + dividend_income_reduced_by_investment_income: 2_000 + # Line 7 = min(10,000, 9,000) = 9,000; line 8 = 0. + dwks09: 9_000 + dwks10: 11_000 + net_capital_gain: 11_000 + +- name: The election is summed across the tax unit + period: 2025 + input: + people: + person1: + long_term_capital_gains: 60_000 + investment_income_elected_form_4952: 10_000 + person2: + qualified_dividend_income: 8_000 + investment_income_elected_form_4952: 55_000 + tax_units: + tax_unit: + members: [person1, person2] + output: + # Line 3 = 65,000; line 4 = 60,000; line 5 = 5,000; line 6 = 3,000. + dividend_income_reduced_by_investment_income: 3_000 + # Line 7 = 60,000; line 8 = 60,000; line 9 = 0. + dwks09: 0 + dwks10: 3_000 + net_capital_gain: 3_000 + +- name: Adjusted net capital gain and worksheet line 13 with an election + period: 2025 + input: + people: + person1: + long_term_capital_gains: 100_000 + qualified_dividend_income: 10_000 + investment_income_elected_form_4952: 30_000 + tax_units: + tax_unit: + members: [person1] + unrecaptured_section_1250_gain: 20_000 + output: + # Line 9 = 100,000 - 30,000 = 70,000; line 10 = 80,000; line 11 = + # 20,000; line 12 = min(70,000, 20,000); line 13 = 80,000 - 20,000. + dwks13: 60_000 + # 26 U.S.C. 1(h)(3): (70,000 - 20,000) + 10,000. + adjusted_net_capital_gain: 60_000 + +- name: Elected dividends are not added back to adjusted net capital gain + period: 2025 + input: + people: + person1: + long_term_capital_gains: 20_000 + qualified_dividend_income: 30_000 + investment_income_elected_form_4952: 35_000 + tax_units: + tax_unit: + members: [person1] + unrecaptured_section_1250_gain: 5_000 + output: + # Line 9 = 0; line 10 = 15,000; line 11 = 5,000; line 12 = 0; + # line 13 = 15,000. + dwks13: 15_000 + # 26 U.S.C. 1(h)(3) and (11)(D)(i): no gain left, plus the 15,000 of + # qualified dividends not elected. + adjusted_net_capital_gain: 15_000 + +- name: The elected gain is taxed at ordinary rates, also in Form 6251 Part III + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + long_term_capital_gains: 300_000 + investment_income_elected_form_4952: 50_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # Single, 2025. Schedule D Tax Worksheet: line 1 = 300,000 - 15,750 = + # 284,250; line 10 = 250,000; line 13 = 250,000; line 14 = 34,250. + # Lines 15-22: 48,350 - 34,250 = 14,100 at 0%. Lines 23-31: line 25 = + # 250,000 - 14,100 = 235,900, line 29 = 284,250 - 48,350 = 235,900, and + # 15% of 235,900 = 35,385. Line 44: tax on line 21 (34,250) = 1,192.50 + + # 2,679 = 3,871.50 at the Tax Computation Worksheet rates, which the + # model uses (the Tax Table gives 3,875). Line 45 = 39,256.50, less + # than line 46 (69,034.75). + # Before the fix line 10 was 300,000 and line 14 zero. + taxable_income: 284_250 + dwks13: 250_000 + dwks14: 34_250 + # The model splits line 45 into the tax at the main rates and the + # section 1(h) capital gains tax. + income_tax_main_rates: 3_871.50 + capital_gains_tax: 35_385 + # Form 6251 Part III: line 12 = 300,000 - 88,100 = 211,900; line 13 = + # 250,000; line 15 = min(250,000 + 0, line 10 = 250,000); line 16 = + # 211,900; line 17 = 0. Line 20 = worksheet line 14 = 34,250; line 21 = + # 48,350 - 34,250 = 14,100; line 22 = 211,900; line 23 = 14,100 at 0%; + # line 24 = 197,800. Line 28 = 14,100 + worksheet line 21 (34,250) = + # 48,350; line 29 = 533,400 - 48,350 = 485,050; line 30 = 197,800; line + # 31 = 29,670; line 38 = 29,670. Before the fix: 24,532.50. + amt_tax_including_cg: 29_670 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.yaml index eee942d4233..acfe1108323 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.yaml @@ -28,8 +28,8 @@ output: amt_section_911_capital_gain_excess: 0 -# Synthetic inputs: on a real return worksheet line 10 and net capital gain -# are the same amount. They are set apart here only to show which one the +# Synthetic inputs: on a real return, and in the model for the same inputs, +# worksheet line 10 and net capital gain are the same amount. They are set apart here only to show which one the # AMT excess reads. - name: The AMT excess reads worksheet line 10, not net_capital_gain period: 2025 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.yaml index c4ca9605f72..f2b4771e3e4 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.yaml @@ -38,3 +38,17 @@ section_911_capital_gain_excess: 0 output: section_911_qualified_dividend_income: 10_000 + +- name: Dividends elected on Form 4952 are not qualified dividend income + period: 2025 + input: + qualified_dividend_income: 30_000 + # A 35,000 election against 20,000 of gain leaves 15,000 of dividends. + net_capital_gain: 15_000 + section_911_capital_gain_excess: 5_000 + output: + # 26 U.S.C. 1(h)(11)(D)(i): the dividends are 15,000, with no gain other + # than dividends; the whole excess comes off them: 15,000 - 5,000. + section_911_qualified_dividend_income: 10_000 + section_911_net_capital_gain: 10_000 + section_911_adjusted_net_capital_gain: 10_000 diff --git a/policyengine_us/tests/test_form_4952_election_worksheet.py b/policyengine_us/tests/test_form_4952_election_worksheet.py new file mode 100644 index 00000000000..e73185087d8 --- /dev/null +++ b/policyengine_us/tests/test_form_4952_election_worksheet.py @@ -0,0 +1,273 @@ +"""Differential and property tests for the Form 4952 line 4g election. + +A taxpayer may elect to treat net capital gain and qualified dividends as +investment income under 26 U.S.C. 163(d)(4)(B). The model applies the +election twice: + +- `net_capital_gain` follows the statute: section 1(h)(2) removes the gain + taken into account under section 163(d)(4)(B)(iii), and section + 1(h)(11)(D)(i) removes elected dividends from qualified dividend income. +- `dividend_income_reduced_by_investment_income`, `dwks09` and `dwks10` + follow lines 2 to 10 of the 2025 Schedule D Tax Worksheet (Instructions + for Schedule D, page 15). + +Both attribute the election first to net capital gain and then to qualified +dividends, as the 2025 Form 4952 line 4g instructions do by default. These +tests check that the two routes, and a line-by-line transcription of the +worksheet below, agree for every combination of gains, losses, capital gain +distributions, dividends and elections. + +Amounts are whole dollars of at most $2,000,000, so every sum the model forms +is below 2**24 and exact in single precision, and the comparisons are exact. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEAR = 2025 + +# --------------------------------------------------------------------------- +# The worksheet and Form 4952 line 4, transcribed. +# --------------------------------------------------------------------------- + + +def form_4952_line_4e(h): + """2025 Form 4952 lines 4d and 4e with every capital asset held for + investment. Line 4d: "the excess, if any, of your total gains over your + total losses ... include capital gain distributions". Line 4e: "the + smaller of line 4d or your net capital gain from the disposition of + property held for investment", which is "the excess, if any, of your net + long-term capital gain over your net short-term capital loss"; capital + gain distributions "are treated as long-term capital gains".""" + long_term = h["long_term"] + h["distributions"] + line_4d = max(0, long_term + h["short_term"]) + net_capital_gain = max(0, long_term - max(0, -h["short_term"])) + return min(line_4d, net_capital_gain) + + +def schedule_d_tax_worksheet(h): + """2025 Schedule D Tax Worksheet lines 2 to 10. Returns lines 6, 9 and 10. + + Capital gain distributions go on Schedule D line 13, so they are part of + lines 15 and 16. + """ + schedule_d_line_15 = h["long_term"] + h["distributions"] + schedule_d_line_16 = schedule_d_line_15 + h["short_term"] + line_2 = h["dividends"] + line_3 = h["election"] + line_4 = form_4952_line_4e(h) + line_5 = max(0, line_3 - line_4) + line_6 = max(0, line_2 - line_5) + line_7 = min(schedule_d_line_15, schedule_d_line_16) + line_8 = min(line_3, line_4) + line_9 = max(0, line_7 - line_8) + line_10 = line_6 + line_9 + return line_6, line_9, line_10 + + +# --------------------------------------------------------------------------- +# The model. +# --------------------------------------------------------------------------- + +OUTPUTS = [ + "dividend_income_reduced_by_investment_income", + "dwks09", + "dwks10", + "dwks13", + "net_capital_gain", + "adjusted_net_capital_gain", +] + + +def build_situation(households): + """One single-person tax unit per household. The election and the + dividends are split between spouses when `split` is set, to check that + the model sums them over the tax unit.""" + people, tax_units, households_out = {}, {}, {} + for i, h in enumerate(households): + head = { + "age": {YEAR: 45}, + "long_term_capital_gains": {YEAR: h["long_term"]}, + "short_term_capital_gains": {YEAR: h["short_term"]}, + "non_sch_d_capital_gains": {YEAR: h["distributions"]}, + "qualified_dividend_income": {YEAR: h["dividends"]}, + "investment_income_elected_form_4952": {YEAR: h["election"]}, + } + members = [f"head_{i}"] + if h["split"]: + half_dividends = h["dividends"] // 2 + half_election = h["election"] // 2 + head["qualified_dividend_income"] = {YEAR: h["dividends"] - half_dividends} + head["investment_income_elected_form_4952"] = { + YEAR: h["election"] - half_election + } + people[f"spouse_{i}"] = { + "age": {YEAR: 45}, + "qualified_dividend_income": {YEAR: half_dividends}, + "investment_income_elected_form_4952": {YEAR: half_election}, + } + members.append(f"spouse_{i}") + people[f"head_{i}"] = head + tax_units[f"tax_unit_{i}"] = { + "members": members, + "unrecaptured_section_1250_gain": {YEAR: h["section_1250"]}, + "capital_gains_28_percent_rate_gain": {YEAR: h["rate_gain_28"]}, + } + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + return {"people": people, "tax_units": tax_units, "households": households_out} + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + return {v: np.asarray(simulation.calculate(v, YEAR)) for v in OUTPUTS} + + +def assert_worksheet_equals_statute(households): + model = calculate(households) + for i, h in enumerate(households): + line_6, line_9, line_10 = schedule_d_tax_worksheet(h) + assert model["dividend_income_reduced_by_investment_income"][i] == line_6, h + assert model["dwks09"][i] == line_9, h + assert model["dwks10"][i] == line_10, h + # The statute route reaches worksheet line 10. + assert model["net_capital_gain"][i] == line_10, h + # Section 1(h)(3) against worksheet line 13: line 10 less the smaller + # of line 9 or line 11 (Schedule D lines 18 and 19). + line_11 = h["section_1250"] + h["rate_gain_28"] + line_13 = line_10 - min(line_9, line_11) + assert model["dwks13"][i] == line_13, h + assert model["adjusted_net_capital_gain"][i] == line_13, h + # Bounds: the election never adds gain, never removes more than it + # elects, and never takes line 10 below zero. + before_election = h["dividends"] + max( + 0, + min(h["long_term"], h["long_term"] + h["short_term"]) + h["distributions"], + ) + assert 0 <= line_10 <= before_election, h + assert line_10 >= before_election - h["election"], h + return model + + +# --------------------------------------------------------------------------- +# Strategies. Bounds keep every model sum below 2**24. +# --------------------------------------------------------------------------- + +DOLLARS = 2_000_000 + + +@st.composite +def households(draw): + long_term = draw( + st.one_of( + st.just(0), + st.integers(-DOLLARS // 4, DOLLARS), + ) + ) + short_term = draw(st.one_of(st.just(0), st.integers(-DOLLARS // 4, DOLLARS // 4))) + distributions = draw(st.one_of(st.just(0), st.integers(0, DOLLARS // 8))) + dividends = draw(st.one_of(st.just(0), st.integers(0, DOLLARS))) + gain = max( + 0, min(long_term + distributions, long_term + distributions + short_term) + ) + # Elections near the gain and near the gain plus dividends are the + # boundaries of the worksheet's min and max steps. + election = draw( + st.one_of( + st.just(0), + st.integers(0, DOLLARS), + st.sampled_from([gain, gain + dividends]), + st.integers(max(0, gain - 5), gain + dividends + 5), + ) + ) + return { + "long_term": long_term, + "short_term": short_term, + "distributions": distributions, + "dividends": dividends, + "election": election, + "section_1250": draw(st.one_of(st.just(0), st.integers(0, DOLLARS // 4))), + "rate_gain_28": draw(st.one_of(st.just(0), st.integers(0, DOLLARS // 4))), + "split": draw(st.booleans()), + } + + +GRID = [ + { + "long_term": long_term, + "short_term": short_term, + "distributions": distributions, + "dividends": dividends, + "election": election, + "section_1250": section_1250, + "rate_gain_28": 0, + "split": split, + } + for long_term, short_term in ( + (0, 0), + (300_000, 0), + (40_000, -10_000), + (40_000, 15_000), + (-5_000, 0), + (5_000, -20_000), + ) + for distributions in (0, 3_000) + for dividends in (0, 30_000) + for election in (0, 2_000, 35_000, 50_000, 400_000) + for section_1250, split in ((0, False), (20_000, True)) +] + + +def test_user_example(): + """A $50,000 election against a $300,000 long-term gain gives worksheet + lines 9 and 10 of $250,000, and the same net capital gain.""" + h = { + "long_term": 300_000, + "short_term": 0, + "distributions": 0, + "dividends": 0, + "election": 50_000, + "section_1250": 0, + "rate_gain_28": 0, + "split": False, + } + assert schedule_d_tax_worksheet(h) == (0, 250_000, 250_000) + model = assert_worksheet_equals_statute([h]) + assert model["dwks10"][0] == 250_000 + assert model["net_capital_gain"][0] == 250_000 + + +def test_grid(): + model = assert_worksheet_equals_statute(GRID) + # The grid reaches each branch: an election inside the gain, one that + # spills into dividends, and one that exceeds both. + assert (model["dwks10"] > 0).any() and (model["dwks10"] == 0).any() + reduced = model["dividend_income_reduced_by_investment_income"] + dividends = np.array([h["dividends"] for h in GRID]) + assert ((reduced > 0) & (reduced < dividends)).any() + + +@hypothesis.settings(max_examples=40, deadline=None, derandomize=True) +@hypothesis.given(st.lists(households(), min_size=1, max_size=25)) +def test_worksheet_equals_statute(batch): + assert_worksheet_equals_statute(batch) + + +@hypothesis.settings(max_examples=15, deadline=None, derandomize=True) +@hypothesis.given(households(), st.integers(0, DOLLARS)) +def test_more_election_never_raises_line_10(h, more): + """Line 10 is non-increasing in the election and falls by at most the + extra amount elected.""" + larger = dict(h, election=h["election"] + more) + model = calculate([h, larger]) + assert model["dwks10"][1] <= model["dwks10"][0] + assert model["dwks10"][0] - model["dwks10"][1] <= more + assert np.array_equal(model["dwks10"], model["net_capital_gain"]) diff --git a/policyengine_us/variables/gov/irs/income/sources/investment_income_form_4952.py b/policyengine_us/variables/gov/irs/income/sources/investment_income_form_4952.py index 40ddc613990..8551304d487 100644 --- a/policyengine_us/variables/gov/irs/income/sources/investment_income_form_4952.py +++ b/policyengine_us/variables/gov/irs/income/sources/investment_income_form_4952.py @@ -4,6 +4,16 @@ class investment_income_form_4952(Variable): value_type = float entity = TaxUnit - label = "Investment income from Form 4952" + label = "Federal Form 4952 line 8 amount for California FTB 3526" + documentation = ( + "The federal Form 4952 amount that California form FTB 3526 line 9 " + "asks for (federal Form 4952 line 8). The federal Schedule D Tax " + "Worksheet does not read this input; it takes the Form 4952 line 4g " + "election from investment_income_elected_form_4952." + ) unit = USD definition_period = YEAR + reference = dict( + title="2021 California form FTB 3526, line 9", + href="https://www.ftb.ca.gov/forms/2021/2021-3526.pdf", + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py index 109837b4226..e43194e2cb6 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/adjusted_net_capital_gain.py @@ -7,7 +7,9 @@ class adjusted_net_capital_gain(Variable): label = "Adjusted net capital gain" unit = USD documentation = ( - "The excess of net long-term capital gain over net short-term capital loss." + "Net capital gain other than qualified dividends, reduced by " + "unrecaptured section 1250 gain and 28-percent rate gain, plus " + "qualified dividends not elected as investment income." ) definition_period = YEAR reference = dict( @@ -24,13 +26,18 @@ def formula(tax_unit, period, parameters): # definition here, but it's a good enough approximation. See 26 U.S. Code § 1(h)(11)(B) for the # definition of 'net capital gain' for the above variable, and 26 U.S. Code § 1(h)(3) for the definition # of adjusted net capital gain (this variable). - qualified_dividend_income = add(tax_unit, period, ["qualified_dividend_income"]) + # Qualified dividend income as defined in paragraph (11), which + # excludes dividends elected as investment income (§ 1(h)(11)(D)(i)). + # net_capital_gain takes a Form 4952 election from the gain before + # the dividends, so the dividends it still holds are the smaller of + # the two amounts. + qualified_dividend_income = min_( + max_(0, add(tax_unit, period, ["qualified_dividend_income"])), + net_capital_gain, + ) unrecaptured_s_1250_gain = tax_unit("unrecaptured_section_1250_gain", period) cg_28_pct_rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) - net_gains_less_dividends = max_( - 0, - net_capital_gain - qualified_dividend_income, - ) + net_gains_less_dividends = net_capital_gain - qualified_dividend_income reduced_capital_gains = max_( net_gains_less_dividends - (unrecaptured_s_1250_gain + cg_28_pct_rate_gain), 0, diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dividend_income_reduced_by_investment_income.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dividend_income_reduced_by_investment_income.py index 7c83c19d68a..c1942cc2082 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dividend_income_reduced_by_investment_income.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dividend_income_reduced_by_investment_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.schedule_d_tax_worksheet_form_4952_lines import ( + schedule_d_tax_worksheet_form_4952_lines, +) class dividend_income_reduced_by_investment_income(Variable): @@ -6,12 +9,30 @@ class dividend_income_reduced_by_investment_income(Variable): entity = TaxUnit definition_period = YEAR label = "Dividend income reduced by investment income" - documentation = "IRS Form 1040 Schedule D worksheet (part 1 of 6)" + documentation = ( + "Schedule D Tax Worksheet line 6: qualified dividends less the part " + "of any Form 4952 line 4g election not attributed to net capital " + "gain. Qualified dividend income after 26 U.S.C. 1(h)(11)(D)(i)." + ) unit = USD + reference = [ + dict( + title="2025 Instructions for Schedule D, Schedule D Tax Worksheet, lines 2-6", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=15", + ), + dict( + title="26 U.S. Code § 1(h)(11)(D)(i)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_11_D_i", + ), + ] def formula(tax_unit, period, parameters): - qualified_dividend_income = add(tax_unit, period, ["qualified_dividend_income"]) - investment_income = tax_unit("investment_income_form_4952", period) - # dwks04 always assumed to be zero - floored_investment_income = max_(0, investment_income) - return max_(0, qualified_dividend_income - floored_investment_income) + # Line 2: qualified dividends (Form 1040 line 3a). + line_2 = add(tax_unit, period, ["qualified_dividend_income"]) + lines = schedule_d_tax_worksheet_form_4952_lines(tax_unit, period) + # Line 5: line 3 minus line 4; if zero or less, zero. The part of the + # election that net capital gain from investment property cannot + # cover comes from qualified dividends. + line_5 = max_(0, lines.line_3 - lines.line_4) + # Line 6: line 2 minus line 5; if zero or less, zero. + return max_(0, line_2 - line_5) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks09.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks09.py index 1b339163d15..c36f008fac5 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks09.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks09.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.schedule_d_tax_worksheet_form_4952_lines import ( + schedule_d_tax_worksheet_form_4952_lines, +) class dwks09(Variable): @@ -6,18 +9,28 @@ class dwks09(Variable): entity = TaxUnit definition_period = YEAR label = "IRS Form 1040 Schedule D worksheet (part 2 of 6)" + documentation = ( + "Schedule D Tax Worksheet line 9: the smaller of Schedule D line 15 " + "or 16, less the part of any Form 4952 line 4g election attributed " + "to net capital gain. Net capital gain other than qualified " + "dividends, after 26 U.S.C. 1(h)(2)." + ) unit = USD - reference = "https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf" + reference = [ + dict( + title="2025 Instructions for Schedule D, Schedule D Tax Worksheet, lines 3-9", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=15", + ), + dict( + title="26 U.S. Code § 1(h)(2)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_2", + ), + ] def formula(tax_unit, period, parameters): - # Schedule D lines 15 and 16 contain capital gains, not qualified - # dividends. Dividends enter separately in dwks10; including them - # here double counts them when short-term gains are positive. - long_term_gains = add(tax_unit, period, ["long_term_capital_gains"]) - net_cg = min_(long_term_gains, tax_unit("net_capital_gains", period)) - other_cg = add(tax_unit, period, ["non_sch_d_capital_gains"]) - mod_cg = where(other_cg > 0, other_cg, max_(0, net_cg) + other_cg) - return max_( - 0, - mod_cg - min_(0, tax_unit("investment_income_form_4952", period)), - ) + lines = schedule_d_tax_worksheet_form_4952_lines(tax_unit, period) + # Line 8: the smaller of line 3 or line 4, the part of the Form 4952 + # line 4g election that comes from net capital gain. + line_8 = min_(lines.line_3, lines.line_4) + # Line 9: line 7 minus line 8; if zero or less, zero. + return max_(0, lines.line_7 - line_8) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks10.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks10.py index 3cd875d37c5..f5e9aafdbf6 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks10.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks10.py @@ -6,27 +6,14 @@ class dwks10(Variable): entity = TaxUnit definition_period = YEAR label = "IRS Form 1040 Schedule D worksheet (part 3 of 6)" + documentation = ( + "Schedule D Tax Worksheet line 10: lines 6 and 9, the qualified " + "dividends and net capital gain left after any Form 4952 line 4g " + "election. Equals net_capital_gain." + ) unit = USD - - def formula(tax_unit, period, parameters): - dwks10_if_gains = add( - tax_unit, - period, - ["dividend_income_reduced_by_investment_income", "dwks09"], - ) - dwks10_if_no_gains = max_( - 0, - min_( - add( - tax_unit, - period, - ["long_term_capital_gains", "qualified_dividend_income"], - ), - tax_unit("net_capital_gains", period), - ), - ) + add(tax_unit, period, ["non_sch_d_capital_gains"]) - return where( - tax_unit("has_qdiv_or_ltcg", period), - dwks10_if_gains, - dwks10_if_no_gains, - ) + reference = dict( + title="2025 Instructions for Schedule D, Schedule D Tax Worksheet, line 10", + href="https://www.irs.gov/pub/irs-prior/i1040sd--2025.pdf#page=15", + ) + adds = ["dividend_income_reduced_by_investment_income", "dwks09"] diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/net_capital_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/net_capital_gain.py index b05d62b053d..d6011f76864 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/net_capital_gain.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/net_capital_gain.py @@ -7,32 +7,58 @@ class net_capital_gain(Variable): label = "Net capital gain" unit = USD documentation = ( - "The excess of net long-term capital gain over net short-term capital" - 'loss, plus qualified dividends (the definition of "net capital gain"' - "which applies to 26 U.S.C. § 1(h) from § 1(h)(11))." + "The excess of net long-term capital gain over net short-term capital " + "loss, reduced by any part of it elected as investment income, plus " + "qualified dividends not elected as investment income (the " + 'definition of "net capital gain" which applies to 26 U.S.C. § 1(h), ' + "from § 1(h)(2) and § 1(h)(11))." ) definition_period = YEAR - reference = dict( - title="26 U.S. Code § 1222(11)", - href="https://www.law.cornell.edu/uscode/text/26/1222#11", - ) + reference = [ + dict( + title="26 U.S. Code § 1222(11)", + href="https://www.law.cornell.edu/uscode/text/26/1222#11", + ), + dict( + title="26 U.S. Code § 1(h)(2)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_2", + ), + dict( + title="26 U.S. Code § 1(h)(11)(A), (D)(i)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_11", + ), + dict( + title="2025 Form 4952, line 4g instructions", + href="https://www.irs.gov/pub/irs-prior/f4952--2025.pdf#page=4", + ), + ] def formula(tax_unit, period, parameters): - lt_capital_gain = max_(0, add(tax_unit, period, ["long_term_capital_gains"])) - st_capital_loss = max_(0, -add(tax_unit, period, ["short_term_capital_gains"])) - investment_income_election = add( + # Capital gain distributions, including those reported without + # Schedule D (Form 1040 line 7 with the box checked), are long-term + # capital gains under IRC 852(b)(3)(B). + long_term_gain = add( tax_unit, period, - ["investment_income_elected_form_4952"], + ["long_term_capital_gains", "non_sch_d_capital_gains"], + ) + short_term_loss = max_(0, -add(tax_unit, period, ["short_term_capital_gains"])) + # 26 U.S.C. 1222(11), determined without regard to section 1(h)(11). + gain = max_(0, long_term_gain - short_term_loss) + # Form 4952 line 4g: the net capital gain and qualified dividends + # elected to be included in investment income (26 U.S.C. 163(d)(4)(B)). + # The amount "is generally treated as being attributable first to net + # capital gain ... and then to qualified dividends" (line 4g + # instructions). Section 1(h)(2) removes the gain part, the amount + # taken into account under 163(d)(4)(B)(iii); section 1(h)(11)(D)(i) + # removes the rest from qualified dividend income. + election = max_( + 0, add(tax_unit, period, ["investment_income_elected_form_4952"]) ) - net_cap_gain = max_( + gain_elected = min_(election, gain) + dividends_elected = election - gain_elected + qualified_dividends = max_( 0, - lt_capital_gain - st_capital_loss - investment_income_election, + add(tax_unit, period, ["qualified_dividend_income"]) - dividends_elected, ) - qual_div_income = add(tax_unit, period, ["qualified_dividend_income"]) - # Capital gain distributions reported without Schedule D (Form 1040 - # line 7 with the box checked) are long-term gains under IRC - # 852(b)(3)(B) and enter the preferential-rate base directly, with no - # Schedule D netting available on that filing path. - non_sch_d_capital_gains = add(tax_unit, period, ["non_sch_d_capital_gains"]) - return net_cap_gain + qual_div_income + non_sch_d_capital_gains + return gain - gain_elected + qualified_dividends diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/schedule_d_tax_worksheet_form_4952_lines.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/schedule_d_tax_worksheet_form_4952_lines.py new file mode 100644 index 00000000000..f3eb7b9d7c7 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/schedule_d_tax_worksheet_form_4952_lines.py @@ -0,0 +1,52 @@ +from collections import namedtuple + +from policyengine_us.model_api import * + +ScheduleDTaxWorksheetForm4952Lines = namedtuple( + "ScheduleDTaxWorksheetForm4952Lines", ["line_3", "line_4", "line_7"] +) + + +def schedule_d_tax_worksheet_form_4952_lines(tax_unit, period): + """Schedule D Tax Worksheet lines 3, 4 and 7 (2025 Instructions for + Schedule D, page 15), shared by dwks09 and + dividend_income_reduced_by_investment_income. + + Line 3 is Form 4952 line 4g, the net capital gain and qualified dividends + the taxpayer elects to include in investment income (26 U.S.C. + 163(d)(4)(B)). Line 4 is Form 4952 line 4e, "the smaller of line 4d or + your net capital gain from the disposition of property held for + investment". Line 7 is "the smaller of line 15 or line 16 of Schedule D". + + The model treats every capital asset as property held for investment, so + Form 4952 line 4d is the excess of total gains over total losses and + line 4e's net capital gain is the excess of net long-term capital gain + over net short-term capital loss (2025 Form 4952 instructions, lines 4d + and 4e). It has no input for the "Elec." amount a taxpayer may write next + to line 4e (the worksheet's line 4 footnote), so the election is + attributed first to net capital gain and then to qualified dividends, as + the line 4g instructions do by default. + + Capital gain distributions reported without Schedule D + (non_sch_d_capital_gains) belong on Schedule D line 13 (2025 Instructions + for Schedule D, "Capital Gain Distributions"), so they enter lines 15 and + 16, and Form 4952 counts them as long-term capital gains. + """ + election = max_(0, add(tax_unit, period, ["investment_income_elected_form_4952"])) + long_term_gains = add(tax_unit, period, ["long_term_capital_gains"]) + short_term_gains = add(tax_unit, period, ["short_term_capital_gains"]) + distributions = add(tax_unit, period, ["non_sch_d_capital_gains"]) + # Schedule D line 15 (net long-term gain or loss, with line 13) and line + # 16 (Schedule D line 7 plus line 15). + schedule_d_line_15 = long_term_gains + distributions + schedule_d_line_16 = tax_unit("net_capital_gains", period) + distributions + # Form 4952 line 4d: total gains over total losses, if any. + net_gain = max_(0, schedule_d_line_16) + # Net capital gain from property held for investment: net long-term + # capital gain over net short-term capital loss, if any. + net_capital_gain = max_(0, schedule_d_line_15 - max_(0, -short_term_gains)) + return ScheduleDTaxWorksheetForm4952Lines( + line_3=election, + line_4=min_(net_gain, net_capital_gain), + line_7=min_(schedule_d_line_15, schedule_d_line_16), + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py index 1aab78c733f..4580d5ca61f 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py @@ -35,9 +35,12 @@ def schedule_d_tax_worksheet_after_capital_gain_excess(tax_unit, period, taxable The excess is measured as the worksheets measure it, from line 10. The section 1(h) formulas use section_911_capital_gain_excess, measured from - net_capital_gain. On a return the two are the same amount; in the model - dwks10 and net_capital_gain are separate formulas that can disagree (they - read different Form 4952 inputs). + net_capital_gain. On a return the two are the same amount, and in the + model dwks10 equals net_capital_gain for the same inputs: both apply the + Form 4952 line 4g election, first to the gain and then to qualified + dividends. With amounts in cents they can differ by single-precision + rounding, because worksheet lines 6 and 9 are stored before dwks10 adds + them. Returns the excess, worksheet lines 9, 10 and 13 and Schedule D line 19. Without an excess these are the first worksheet's amounts, unchanged. diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py index a024c8c3860..1cc7a77763a 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py @@ -15,9 +15,9 @@ class section_911_capital_gain_excess(Variable): "at the capital gains rates. The section 1(h) formulas use this " "amount; the Schedule D Tax Worksheet lines (dwks14, dwks19, " "regular_tax_before_credits) measure the excess from worksheet line " - "10, as the worksheet does. On a return the two are the same amount; " - "in the model dwks10 and net_capital_gain are separate formulas that " - "can disagree." + "10, as the worksheet does. On a return the two are the same amount, " + "and in the model dwks10 equals net_capital_gain for the same inputs, " + "apart from single-precision rounding." ) definition_period = YEAR reference = [ diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.py index 1864f9fb7f3..3cfc0d25094 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.py @@ -7,14 +7,19 @@ class section_911_qualified_dividend_income(Variable): label = "Qualified dividends less the section 911 capital gain excess" unit = USD documentation = ( - "Qualified dividend income for the regular tax rates. A taxpayer with " + "Qualified dividend income for the regular tax rates, without " + "dividends elected as investment income on Form 4952. A taxpayer with " "a section 911 capital gain excess reduces qualified dividends by the " "part of the excess that exceeds net capital gain other than " - "qualified dividends. Equals qualified dividend income for everyone " - "else." + "qualified dividends. Equals that qualified dividend income for " + "everyone else." ) definition_period = YEAR reference = [ + dict( + title="26 U.S. Code § 1(h)(11)(D)(i)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_11_D_i", + ), dict( title="26 U.S. Code § 911(f)(2)(A)(ii)", href="https://www.law.cornell.edu/uscode/text/26/911#f_2_A_ii", @@ -26,8 +31,16 @@ class section_911_qualified_dividend_income(Variable): ] def formula(tax_unit, period, parameters): - qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) net_capital_gain = tax_unit("net_capital_gain", period) + # Qualified dividend income as section 1(h)(11) defines it, without + # dividends elected as investment income (§ 1(h)(11)(D)(i)). + # net_capital_gain takes a Form 4952 election from the gain before the + # dividends, so the dividends it still holds are the smaller of the + # two amounts. + qualified_dividends = min_( + max_(0, add(tax_unit, period, ["qualified_dividend_income"])), + net_capital_gain, + ) excess = tax_unit("section_911_capital_gain_excess", period) # Net capital gain determined without regard to section 1(h)(11). gain_other_than_dividends = max_(0, net_capital_gain - qualified_dividends) diff --git a/policyengine_us/variables/household/income/person/misc/investment_income_elected_form_4952.py b/policyengine_us/variables/household/income/person/misc/investment_income_elected_form_4952.py index b716a3864d5..a9a877b7a38 100644 --- a/policyengine_us/variables/household/income/person/misc/investment_income_elected_form_4952.py +++ b/policyengine_us/variables/household/income/person/misc/investment_income_elected_form_4952.py @@ -5,5 +5,20 @@ class investment_income_elected_form_4952(Variable): value_type = float entity = Person label = "investment income elected on Form 4952" + documentation = ( + "Form 4952 line 4g: net capital gain and qualified dividends the " + "taxpayer elects to include in investment income, which then lose " + "the capital gains tax rates. Schedule D Tax Worksheet line 3." + ) unit = USD definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 163(d)(4)(B)", + href="https://www.law.cornell.edu/uscode/text/26/163#d_4_B", + ), + dict( + title="2025 Form 4952, line 4g", + href="https://www.irs.gov/pub/irs-prior/f4952--2025.pdf", + ), + ]