diff --git a/changelog.d/ncg-filer-gains-floor-distributions.fixed.md b/changelog.d/ncg-filer-gains-floor-distributions.fixed.md new file mode 100644 index 00000000000..16c116afe33 --- /dev/null +++ b/changelog.d/ncg-filer-gains-floor-distributions.fixed.md @@ -0,0 +1 @@ +Figure net capital gain and the Schedule D Tax Worksheet from the head and spouse's Schedule D, qualified dividends and Form 4952 election only, leaving out tax unit dependents' amounts as adjusted gross income does, and treat each filer's negative capital gain distributions input as zero. Apply the same filer-only scope in Hawaii's alternative capital gains tax worksheet and in the qualified dividends Vermont's flat capital gains exclusion subtracts. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.yaml index 2ee2e74bbcb..9444db22c4e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.yaml @@ -180,6 +180,65 @@ schedule_d_unrecaptured_section_1250_gain: 208_514.11 adjusted_net_capital_gain: 290_510.39 +- name: A dependent's collectibles gain is on the dependent's own return + period: 2025 + input: + people: + head: + age: 45 + long_term_capital_gains: 10_000 + child: + age: 12 + is_tax_unit_dependent: true + long_term_capital_gains: 5_000 + collectibles_gain_or_loss: 5_000 + tax_units: + tax_unit: + members: [head, child] + output: + # The head's worksheet has no collectibles gain. + capital_gains_28_percent_rate_gain: 0 + net_capital_gain: 10_000 + adjusted_net_capital_gain: 10_000 + +- name: A dependent's Schedule D line 18 amounts are on the dependent's own return + period: 2025 + input: + people: + head: + age: 45 + long_term_capital_gains: 10_000 + child: + age: 12 + is_tax_unit_dependent: true + long_term_capital_gains: 5_000 + long_term_capital_gains_on_collectibles: 2_000 + long_term_capital_gains_on_small_business_stock: 1_000 + tax_units: + tax_unit: + members: [head, child] + output: + capital_gains_28_percent_rate_gain: 0 + +- name: A dependent's short-term loss does not reduce the head's 28 percent rate gain + period: 2025 + input: + people: + head: + age: 45 + long_term_capital_gains: 50_000 + collectibles_gain_or_loss: 10_000 + child: + age: 12 + is_tax_unit_dependent: true + short_term_capital_gains: -8_000 + tax_units: + tax_unit: + members: [head, child] + output: + # Line 6 is the head's Schedule D line 7 loss: none. + capital_gains_28_percent_rate_gain: 10_000 + # The 2025 Schedule D instructions' 28% Rate Gain Worksheet (page 11) and # Unrecaptured Section 1250 Gain Worksheet (page 13) apply to the filer's # return. Include a joint filer's spouse, but exclude dependent amounts. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml index 734071018ad..1b8d0064fed 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml @@ -12,3 +12,278 @@ members: [person1] output: net_capital_gain: 10_500 + +# 26 U.S.C. 1222(11): net capital gain is the net long-term capital gain over +# the net short-term capital loss, Schedule D lines 15 and 7 of the 2025 +# Schedule D. Section 1(h)(11)(A) adds qualified dividend income. These are +# amounts on the head and spouse's return. A tax unit dependent's gains, +# losses and dividends are on the dependent's own return (2025 Form 8814, line +# 4), as in adjusted gross income. + +- name: A dependent's long-term gain is not on the head's Schedule D + period: 2025 + input: + people: + head: + age: 40 + employment_income: 60_000 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 20_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Schedule D line 15 = 10,000 (the head's), line 16 = 10,000. + net_capital_gain: 10_000 + adjusted_net_capital_gain: 10_000 + dwks09: 10_000 + dwks10: 10_000 + has_qdiv_or_ltcg: true + # The child's gain is not in gross income either: 60,000 + 10,000. + adjusted_gross_income: 70_000 + # Head of household: 70,000 - 23,625 standard deduction. + taxable_income: 46_375 + # Ordinary income 36,375: 10% x 17,000 + 12% x 19,375 = 1,700 + 2,325. + # The 10,000 gain is below the 64,750 zero-rate maximum, so 0% on it. + # Counting the child's gain too (30,000) gave 1,637.50. + income_tax_before_credits: 4_025 + +- name: A dependent's long-term gain, filing single + period: 2025 + input: + people: + head: + age: 40 + employment_income: 60_000 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 20_000 + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + households: + household: + members: [head, child] + state_code: TX + output: + net_capital_gain: 10_000 + # 70,000 - 15,750 standard deduction. + taxable_income: 54_250 + # Ordinary income 44,250: 10% x 11,925 + 12% x 32,325 = 1,192.50 + + # 3,879. Of the 10,000 gain, 48,350 - 44,250 = 4,100 is at 0% and 5,900 + # at 15% (885). Counting the child's gain too (30,000) gave 3,556.50. + income_tax_before_credits: 5_956.5 + +- name: A dependent's short-term loss does not reduce the head's net capital gain + period: 2025 + input: + people: + head: + age: 40 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + short_term_capital_gains: -8_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # The head's Schedule D line 7 is 0 and line 15 is 10,000. + net_capital_gain: 10_000 + dwks09: 10_000 + adjusted_gross_income: 10_000 + +- name: Spouses' gains and losses net, and a dependent's stay off the return + period: 2025 + input: + people: + head: + age: 40 + long_term_capital_gains: 10_000 + spouse: + age: 40 + short_term_capital_gains: -4_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: 20_000 + short_term_capital_gains: -1_000 + non_sch_d_capital_gains: 1_000 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + # Joint Schedule D: line 7 = (4,000), line 15 = 10,000, line 16 = 6,000. + # 1222(11): 10,000 - 4,000. + net_capital_gain: 6_000 + dwks09: 6_000 + adjusted_gross_income: 6_000 + +- name: A dependent's qualified dividends are not on the head's return + period: 2025 + input: + people: + head: + age: 45 + employment_income: 150_000 + long_term_capital_gains: 10_000 + child: + age: 12 + is_tax_unit_dependent: true + qualified_dividend_income: 20_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # Form 1040 line 3a is the head's: 0. + dividend_income_reduced_by_investment_income: 0 + net_capital_gain: 10_000 + adjusted_net_capital_gain: 10_000 + adjusted_gross_income: 160_000 + # Head of household: 160,000 - 23,625 = 136,375; ordinary income 126,375: + # 1,700 + 12% x 47,850 + 22% x 38,500 + 24% x 23,025 = 1,700 + 5,742 + + # 8,470 + 5,526; the 10,000 gain at 15% (1,500). Counting the child's + # dividends too taxed 20,000 of the head's income at 15% rather than 24% + # (21,138). + income_tax_before_credits: 22_938 + +- name: A dependent's qualified dividends alone do not call for the worksheet + period: 2025 + input: + people: + head: + age: 45 + employment_income: 60_000 + child: + age: 12 + is_tax_unit_dependent: true + qualified_dividend_income: 5_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # No qualified dividends on Form 1040 line 3a and no Schedule D. + has_qdiv_or_ltcg: false + net_capital_gain: 0 + +- name: A dependent's investment income election is on the dependent's own Form 4952 + period: 2025 + input: + people: + head: + age: 45 + long_term_capital_gains: 10_000 + qualified_dividend_income: 2_000 + child: + age: 12 + is_tax_unit_dependent: true + investment_income_elected_form_4952: 500 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # The head elects nothing: 10,000 + 2,000. + net_capital_gain: 12_000 + +- name: A negative distributions input counts as zero in net capital gain + period: 2025 + input: + people: + head: + age: 45 + employment_income: 100_000 + long_term_capital_gains: 10_000 + non_sch_d_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head] + households: + household: + members: [head] + state_code: TX + output: + # Form 1099-DIV box 2a, total capital gain distributions, is not + # negative. Schedule D line 13 = 0, line 15 = 10,000, as in AGI. + adjusted_gross_income: 110_000 + net_capital_gain: 10_000 + dwks09: 10_000 + # Taxable income 110,000 - 15,750 = 94,250; ordinary income 84,250: + # 1,192.50 + 12% x 36,550 + 22% x 35,775 = 1,192.50 + 4,386 + 7,870.50; + # the 10,000 gain at 15% (1,500). With the input subtracted (8,000) it + # was 15,089. + income_tax_before_credits: 14_949 + +- name: Supplied adult dependent roles keep capital income off the parent's return + period: 2025 + input: + people: + parent: + age: 50 + tax_unit_role_input: HEAD + employment_income: 150_000 + long_term_capital_gains: 10_000 + student: + age: 20 + is_full_time_student: true + tax_unit_role_input: DEPENDENT + long_term_capital_gains: 20_000 + short_term_capital_gains: -5_000 + non_sch_d_capital_gains: 9_000 + qualified_dividend_income: 5_000 + collectibles_gain_or_loss: 5_000 + investment_income_elected_form_4952: 3_000 + tax_units: + tax_unit: + members: [parent, student] + marital_units: + parent_unit: + members: [parent] + student_unit: + members: [student] + households: + household: + members: [parent, student] + state_code: TX + output: + is_tax_unit_dependent: [false, true] + filing_status: HEAD_OF_HOUSEHOLD + adjusted_gross_income: 160_000 + net_capital_gain: 10_000 + dwks09: 10_000 + dwks10: 10_000 + dividend_income_reduced_by_investment_income: 0 + capital_gains_28_percent_rate_gain: 0 + capital_gains_tax: 1_500 + # The same filer income and HOH tax calculation as the child-dividends case. + regular_tax_before_credits: 22_938 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 index 55bc9ed9711..4a9bee2064e 100644 --- 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 @@ -11,8 +11,9 @@ # 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. +# line 10 dwks10. With component-derived inputs, line 10 equals +# net_capital_gain, which applies 26 U.S.C. 1(h)(2) and 1(h)(11)(D)(i). +# The supplied-aggregate case below records a preexisting disagreement. - name: An election within the gain comes off the gain period: 2025 @@ -293,3 +294,56 @@ # 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 + +- name: A dependent's gains are not in the head's worksheet or Form 4952 + period: 2025 + input: + people: + head: + age: 45 + long_term_capital_gains: 2_000 + qualified_dividend_income: 6_000 + investment_income_elected_form_4952: 5_000 + child: + age: 12 + is_tax_unit_dependent: true + long_term_capital_gains: 10_000 + qualified_dividend_income: 1_000 + tax_units: + tax_unit: + members: [head, child] + output: + # Line 2 = 6,000; line 3 = 5,000; line 4 = min(2,000, 2,000) = 2,000; + # line 5 = 3,000; line 6 = 3,000. + dividend_income_reduced_by_investment_income: 3_000 + # Line 7 = min(2,000, 2,000); line 8 = min(5,000, 2,000); line 9 = 0. + dwks09: 0 + dwks10: 3_000 + net_capital_gain: 3_000 + +- name: A tax unit net_capital_gains input reaches Schedule D line 16 + period: 2025 + input: + people: + person1: + age: 45 + non_sch_d_capital_gains: 1_000 + tax_units: + tax_unit: + members: [person1] + net_capital_gains: -5_000 + households: + household: + members: [person1] + state_code: TX + output: + # Schedule D line 15 = 1,000 (line 13); line 16 = -5,000 + 1,000 = + # -4,000, a loss, so neither tax worksheet applies (2025 Instructions + # for Form 1040, line 16) and worksheet line 7 = min(1,000, -4,000). + has_qdiv_or_ltcg: false + dwks09: 0 + dwks10: 0 + # Preserve the preexisting aggregate/component split: the statutory + # path uses person-level lines 15 and 7, rather than supplied line 16. + net_capital_gain: 1_000 + adjusted_net_capital_gain: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.yaml index f57f2ebdce4..c84287acaea 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/schedule_d_unrecaptured_section_1250_gain.yaml @@ -139,6 +139,25 @@ capital_gains_28_percent_rate_gain: 0 schedule_d_unrecaptured_section_1250_gain: 20_000 +- name: A dependent's short-term loss does not reduce the head's line 19 + period: 2025 + input: + people: + head: + age: 45 + long_term_capital_gains: 50_000 + child: + age: 12 + is_tax_unit_dependent: true + short_term_capital_gains: -6_000 + tax_units: + tax_unit: + members: [head, child] + unrecaptured_section_1250_gain_before_losses: 20_000 + output: + # Line 15 is the head's Schedule D line 7 loss: none, so line 18 = 20,000. + schedule_d_unrecaptured_section_1250_gain: 20_000 + # The 2025 Schedule D instructions' 28% Rate Gain Worksheet (page 11) and # Unrecaptured Section 1250 Gain Worksheet (page 13) apply to the filer's # return. Include a joint filer's spouse, but exclude dependent amounts. diff --git a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/alternative_tax/hi_taxable_income_for_alternative_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/alternative_tax/hi_taxable_income_for_alternative_tax.yaml index 635cc2d2e26..43d29641202 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/alternative_tax/hi_taxable_income_for_alternative_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/alternative_tax/hi_taxable_income_for_alternative_tax.yaml @@ -37,3 +37,28 @@ hi_capital_gain_for_alternative_tax: 50_000 output: hi_taxable_income_for_alternative_tax: 48_000 + +- name: A dependent's long-term loss does not reduce the head's worksheet line 4 + period: 2025 + input: + people: + head: + age: 45 + long_term_capital_gains: 10_000 + child: + age: 10 + is_tax_unit_dependent: true + long_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + hi_taxable_income: 200_000 + households: + household: + members: [head, child] + state_code: HI + output: + # Line 4 = 10,000 (the head's) and line 7 = 10,000, so line 8 = 10,000: + # 200,000 - 10,000, above the capped taxable income. + hi_taxable_income_for_alternative_tax: 190_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/agi/vt_capital_gains_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/agi/vt_capital_gains_exclusion.yaml index 435ba557c67..403e00d94bc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/agi/vt_capital_gains_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/agi/vt_capital_gains_exclusion.yaml @@ -84,3 +84,29 @@ output: # Only flat $5,000 applies (financial instruments are NOT eligible for 40%) vt_capital_gains_exclusion: 5_000 + +- name: A dependent's qualified dividends do not reduce the head's flat exclusion + period: 2025 + input: + people: + head: + age: 45 + employment_income: 50_000 + long_term_capital_gains: 4_000 + child: + age: 10 + is_tax_unit_dependent: true + qualified_dividend_income: 3_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: VT + output: + # The head's adjusted net capital gain is 4,000 and has no qualified + # dividends; the child's 3,000 is on the child's own return. + adjusted_net_capital_gain: 4_000 + # min(4,000, 5,000); 40% of taxable income (54,000 - 23,625) is 12,150. + vt_capital_gains_exclusion: 4_000 diff --git a/policyengine_us/tests/test_schedule_d_filer_net_capital_gain.py b/policyengine_us/tests/test_schedule_d_filer_net_capital_gain.py new file mode 100644 index 00000000000..8074bd8cf55 --- /dev/null +++ b/policyengine_us/tests/test_schedule_d_filer_net_capital_gain.py @@ -0,0 +1,446 @@ +"""Property tests: the tax on net capital gain uses the filers' Schedule D. + +26 U.S.C. 1(h) taxes a net capital gain at lower rates. Net capital gain is +the net long-term capital gain over the net short-term capital loss (26 U.S.C. +1222(11)), plus qualified dividend income (1(h)(11)(A)). On the return these +are Schedule D lines 15 and 16 (2025 Schedule D) and Form 1040 line 3a, and +the Schedule D Tax Worksheet starts from "the smaller of line 15 or line 16" +(2025 Instructions for Schedule D, page 15). Capital gain distributions +reported without Schedule D go on line 13, inside line 15 (2025 Instructions +for Schedule D, "Capital Gain Distributions"). Form 1099-DIV box 2a reports +them, so they are never negative. + +A tax unit dependent's gains, losses and dividends are on the dependent's own +return (2025 Form 8814, line 4), and adjusted gross income leaves them out. +So for every combination of the filers' long-term (LT) and short-term (ST) +gains and losses, distributions (D) and qualified dividends (Q), and anything +the tax unit's dependents have, with no filer Form 4952 election: + +1. net_capital_gain = max(0, min(line 15, line 16)) + Q, where line 15 is + LT + max(0, D) and line 16 is line 15 + ST, over the head and spouse. +2. The Schedule D Tax Worksheet agrees: line 9 (dwks09) is + max(0, min(line 15, line 16)), line 6 is Q, and line 10 (dwks10) equals + net_capital_gain, which figures the same amount from 1222(11). This is a + differential check between the two paths. +3. has_qdiv_or_ltcg is true exactly when Q > 0 or lines 15 and 16 are both + more than zero (Form 1040 line 16 instructions). +4. Form 1040 line 7a (filer_loss_limited_net_capital_gains) is line 16, or + the loss limit if line 16 is a larger loss, from the same lines. +5. Changing a dependent's capital gains, losses, distributions, dividends, + collectibles and section 1202 gain, long-term loss carryover, Schedule D + line 18 amounts or Form 4952 election changes none of the filers' tax + amounts. +6. A negative distributions input gives the same results as zero. +7. Net capital gain never falls when a filer's LT, ST, D or Q rises, and + rises by no more than the increase. +8. With a nonnegative filer election E, net capital gain is + max(0, G + Q - E), where G = max(0, min(line 15, line 16)). Worksheet + line 9 is max(0, G - E) and line 6 is max(0, Q - max(0, E - G)): + the election consumes gain before dividends. These paths agree, and + regular tax before credits equals ordinary tax plus capital gains tax. + +These component-derived properties do not set a tax-unit net_capital_gains +override; the supplied-aggregate contract is covered separately in YAML. + +Amounts are whole dollars of at most $1,000,000, so every sum is exact in +single precision and the comparisons are exact. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2025 + +FILER_INPUTS = { + "long_term": "long_term_capital_gains", + "short_term": "short_term_capital_gains", + "distributions": "non_sch_d_capital_gains", + "dividends": "qualified_dividend_income", +} +DEPENDENT_INPUTS = { + **FILER_INPUTS, + "collectibles": "collectibles_gain_or_loss", + "line_18_collectibles": "long_term_capital_gains_on_collectibles", + "line_18_small_business_stock": "long_term_capital_gains_on_small_business_stock", + "section_1202": "section_1202_gain", + "carryover": "long_term_capital_loss_carryover", + "election": "investment_income_elected_form_4952", +} + +# The head and spouse's amounts, which must not depend on a dependent's. +TAX_OUTPUTS = [ + "net_capital_gain", + "adjusted_net_capital_gain", + "dwks09", + "dwks10", + "dividend_income_reduced_by_investment_income", + "has_qdiv_or_ltcg", + "capital_gains_28_percent_rate_gain", + "schedule_d_unrecaptured_section_1250_gain", + "filer_loss_limited_net_capital_gains", + "adjusted_gross_income", + "taxable_income", + "capital_gains_tax", + "income_tax_main_rates", + "alternative_minimum_tax", + "regular_tax_before_credits", + "income_tax_before_credits", +] + + +def schedule_d_lines(h): + """Schedule D lines 15 and 16 and Form 1040 line 3a of the filers.""" + filers = h["filers"] + line_15 = sum(p["long_term"] + max(0, p["distributions"]) for p in filers) + line_16 = line_15 + sum(p["short_term"] for p in filers) + dividends = sum(p["dividends"] for p in filers) + return line_15, line_16, dividends + + +def build_situation(households): + """One tax unit per household: a head, an optional spouse and any + dependents, each dependent in a marital unit of their own.""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + for i, h in enumerate(households): + filers, members = [], [] + for j, p in enumerate(h["filers"]): + name = f"filer_{i}_{j}" + people[name] = { + "age": {YEAR: 45}, + "is_tax_unit_head": {YEAR: j == 0}, + "is_tax_unit_spouse": {YEAR: j == 1}, + "is_tax_unit_dependent": {YEAR: False}, + "employment_income": {YEAR: h["wages"] if j == 0 else 0}, + "investment_income_elected_form_4952": {YEAR: p.get("election", 0)}, + **{v: {YEAR: p[k]} for k, v in FILER_INPUTS.items()}, + } + filers.append(name) + for j, p in enumerate(h["dependents"]): + name = f"dependent_{i}_{j}" + people[name] = { + "age": {YEAR: 10}, + "is_tax_unit_head": {YEAR: False}, + "is_tax_unit_spouse": {YEAR: False}, + "is_tax_unit_dependent": {YEAR: True}, + "employment_income": {YEAR: 0}, + **{v: {YEAR: p.get(k, 0)} for k, v in DEPENDENT_INPUTS.items()}, + } + marital_units[f"dependent_marital_unit_{i}_{j}"] = {"members": [name]} + members.append(name) + members = filers + members + tax_units[f"tax_unit_{i}"] = {"members": members} + marital_units[f"marital_unit_{i}"] = {"members": filers} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households_out, + } + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + results = {v: np.asarray(simulation.calculate(v, YEAR)) for v in TAX_OUTPUTS} + results["filing_status"] = np.asarray( + simulation.calculate("filing_status", YEAR).decode_to_str() + ) + return results + + +def assert_properties(households): + model = calculate(households) + for i, h in enumerate(households): + line_15, line_16, dividends = schedule_d_lines(h) + gain = max(0, min(line_15, line_16)) + # 1. 26 U.S.C. 1222(11) and 1(h)(11)(A) on the filers' return. + assert model["net_capital_gain"][i] == gain + dividends, h + # 2. The Schedule D Tax Worksheet, lines 6, 9 and 10. + assert model["dividend_income_reduced_by_investment_income"][i] == ( + dividends + ), h + assert model["dwks09"][i] == gain, h + assert model["dwks10"][i] == model["net_capital_gain"][i], h + # 3. Form 1040 line 16 routing. + assert bool(model["has_qdiv_or_ltcg"][i]) == ( + dividends > 0 or (line_15 > 0 and line_16 > 0) + ), h + # 4. Form 1040 line 7a, Schedule D line 16 or line 21. + limit = 1_500 if model["filing_status"][i] == "SEPARATE" else 3_000 + assert model["filer_loss_limited_net_capital_gains"][i] == max( + -limit, line_16 + ), h + # No collectibles, section 1202 or section 1250 gain on the return. + assert model["adjusted_net_capital_gain"][i] == model["net_capital_gain"][i], h + + +def without_dependents_amounts(households): + return [{**h, "dependents": [{} for _ in h["dependents"]]} for h in households] + + +def assert_dependents_change_nothing(households): + """5. The filers' amounts with the dependents' inputs and without them.""" + with_amounts = calculate(households) + without_amounts = calculate(without_dependents_amounts(households)) + for variable in TAX_OUTPUTS: + assert np.array_equal(with_amounts[variable], without_amounts[variable]), ( + variable, + households, + ) + + +# --------------------------------------------------------------------------- +# A deterministic grid. +# --------------------------------------------------------------------------- + +GRID_GAINS = [-12_000, -3_000, 0, 4_000, 25_000] +GRID_DISTRIBUTIONS = [-2_000, 0, 3_000] +GRID_DEPENDENT = { + "long_term": 20_000, + "short_term": -8_000, + "distributions": 1_000, + "dividends": 5_000, + "collectibles": 4_000, + "line_18_collectibles": 2_000, + "line_18_small_business_stock": 1_000, + "section_1202": 3_000, + "carryover": 2_000, + "election": 3_000, +} + + +def grid_households(): + households = [] + for lt, st_, d, q in itertools.product( + GRID_GAINS, GRID_GAINS, GRID_DISTRIBUTIONS, [0, 2_000] + ): + filer = {"long_term": lt, "short_term": st_, "distributions": d, "dividends": q} + none = {"long_term": 0, "short_term": 0, "distributions": 0, "dividends": 0} + households.append( + {"wages": 60_000, "filers": [filer], "dependents": [GRID_DEPENDENT]} + ) + # The same totals split between spouses, with two dependents. + households.append( + { + "wages": 150_000, + "filers": [ + {**none, "long_term": lt, "distributions": d}, + {**none, "short_term": st_, "dividends": q}, + ], + "dependents": [GRID_DEPENDENT, {"long_term": -5_000}], + } + ) + return households + + +def test_grid(): + households = grid_households() + assert_properties(households) + assert_dependents_change_nothing(households) + + +def test_reported_case(): + """A single filer with wages 60,000 and a 10,000 long-term gain, and a + dependent child with a 20,000 long-term gain: the child's gain is on the + child's own return, so net capital gain is 10,000, not 30,000.""" + h = { + "wages": 60_000, + "filers": [ + {"long_term": 10_000, "short_term": 0, "distributions": 0, "dividends": 0} + ], + "dependents": [{"long_term": 20_000}], + } + model = calculate([h]) + assert model["net_capital_gain"][0] == 10_000 + assert model["dwks10"][0] == 10_000 + assert model["adjusted_gross_income"][0] == 70_000 + + +# --------------------------------------------------------------------------- +# Hypothesis. +# --------------------------------------------------------------------------- + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +AMOUNT = st.integers(min_value=-1_000_000, max_value=1_000_000) +SMALL = st.integers(min_value=-20_000, max_value=20_000) +NON_NEGATIVE = st.one_of( + st.just(0), + st.integers(min_value=0, max_value=20_000), + st.integers(min_value=0, max_value=1_000_000), +) +# Distributions inputs, including negative ones, which count as zero. +DISTRIBUTIONS = st.one_of(NON_NEGATIVE, st.integers(min_value=-50_000, max_value=-1)) +WAGES = st.sampled_from([0, 40_000, 150_000, 600_000]) + + +@st.composite +def filer(draw): + amounts = draw(st.sampled_from([AMOUNT, SMALL])) + return { + "long_term": draw(st.one_of(st.just(0), amounts)), + "short_term": draw(st.one_of(st.just(0), amounts)), + "distributions": draw(DISTRIBUTIONS), + "dividends": draw(NON_NEGATIVE), + } + + +@st.composite +def dependent(draw): + return { + "long_term": draw(st.one_of(st.just(0), SMALL, AMOUNT)), + "short_term": draw(st.one_of(st.just(0), SMALL, AMOUNT)), + "distributions": draw(DISTRIBUTIONS), + "dividends": draw(NON_NEGATIVE), + "collectibles": draw(st.one_of(st.just(0), SMALL)), + "line_18_collectibles": draw(st.one_of(st.just(0), NON_NEGATIVE)), + "line_18_small_business_stock": draw(st.one_of(st.just(0), NON_NEGATIVE)), + "section_1202": draw(st.one_of(st.just(0), NON_NEGATIVE)), + "carryover": draw(st.one_of(st.just(0), NON_NEGATIVE)), + "election": draw(st.one_of(st.just(0), NON_NEGATIVE)), + } + + +@st.composite +def household(draw): + return { + "wages": draw(WAGES), + "filers": [draw(filer()) for _ in range(draw(st.integers(1, 2)))], + "dependents": [draw(dependent()) for _ in range(draw(st.integers(0, 2)))], + } + + +@st.composite +def household_with_election(draw): + h = draw(household()) + return { + **h, + "filers": [{**p, "election": draw(NON_NEGATIVE)} for p in h["filers"]], + } + + +@hypothesis.settings(max_examples=12, deadline=None) +@hypothesis.given(st.lists(household_with_election(), min_size=1, max_size=4)) +@hypothesis.example( + [ + { + "wages": 150_000, + "filers": [ + { + "long_term": 20_000, + "short_term": -3_000, + "distributions": 1_000, + "dividends": 2_000, + "election": 10_000, + }, + { + "long_term": -5_000, + "short_term": 2_000, + "distributions": 0, + "dividends": 3_000, + "election": 8_000, + }, + ], + "dependents": [GRID_DEPENDENT], + } + ] +) +def test_filer_election_composes_with_schedule_d_and_regular_tax(households): + """Gain-first elections agree across paths and ignore dependent inputs.""" + model = calculate(households) + without_amounts = calculate(without_dependents_amounts(households)) + for i, h in enumerate(households): + line_15, line_16, dividends = schedule_d_lines(h) + gain = max(0, min(line_15, line_16)) + election = sum(p["election"] for p in h["filers"]) + assert model["net_capital_gain"][i] == max(0, gain + dividends - election), h + assert model["dwks09"][i] == max(0, gain - election), h + assert model["dividend_income_reduced_by_investment_income"][i] == max( + 0, dividends - max(0, election - gain) + ), h + assert model["dwks10"][i] == model["net_capital_gain"][i], h + np.testing.assert_array_equal( + model["regular_tax_before_credits"], + model["income_tax_main_rates"] + model["capital_gains_tax"], + ) + for variable in TAX_OUTPUTS: + assert np.array_equal(model[variable], without_amounts[variable]), ( + variable, + households, + ) + + +@hypothesis.settings(max_examples=30, deadline=None) +@hypothesis.given(st.lists(household(), min_size=1, max_size=8)) +def test_properties(households): + assert_properties(households) + + +@hypothesis.settings(max_examples=20, deadline=None) +@hypothesis.given(st.lists(household(), min_size=1, max_size=8)) +def test_dependents_change_nothing_on_the_filers_return(households): + assert_dependents_change_nothing(households) + + +@hypothesis.settings(max_examples=20, deadline=None) +@hypothesis.given(st.lists(household(), min_size=1, max_size=8)) +def test_negative_distributions_count_as_zero(households): + """6. Flooring each filer's distributions at zero changes nothing.""" + floored = [ + { + **h, + "filers": [ + {**p, "distributions": max(0, p["distributions"])} for p in h["filers"] + ], + } + for h in households + ] + model = calculate(households) + reference = calculate(floored) + for variable in TAX_OUTPUTS: + assert np.array_equal(model[variable], reference[variable]), variable + + +@hypothesis.settings(max_examples=20, deadline=None) +@hypothesis.given( + st.lists(household(), min_size=1, max_size=8), + st.sampled_from(sorted(FILER_INPUTS)), + st.integers(min_value=1, max_value=50_000), +) +def test_net_capital_gain_rises_with_the_filers_gains(households, item, extra): + """7. Net capital gain is non-decreasing in each of the head's LT, ST, + D (once it is not negative) and Q, and rises by at most the increase.""" + households = [ + { + **h, + "filers": [ + { + **h["filers"][0], + "distributions": max(0, h["filers"][0]["distributions"]), + } + ] + + h["filers"][1:], + } + for h in households + ] + raised = [ + { + **h, + "filers": [{**h["filers"][0], item: h["filers"][0][item] + extra}] + + h["filers"][1:], + } + for h in households + ] + base = calculate(households)["net_capital_gain"] + more = calculate(raised)["net_capital_gain"] + assert (more >= base).all() + assert (more - base <= extra).all() diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py index ac66f03c566..2109e5f3861 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/has_qdiv_or_ltcg.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.filer_schedule_d_lines import ( + filer_schedule_d_lines, +) class has_qdiv_or_ltcg(Variable): @@ -7,8 +10,8 @@ class has_qdiv_or_ltcg(Variable): label = "Has qualified dividends or long-term capital gains" documentation = ( "Whether this tax unit figures its tax with the Qualified Dividends " - "and Capital Gain Tax Worksheet or the Schedule D Tax Worksheet: it " - "has qualified dividends, or the head and spouse's Schedule D lines " + "and Capital Gain Tax Worksheet or the Schedule D Tax Worksheet: the " + "head and spouse have qualified dividends, or their Schedule D lines " "15 and 16 are both more than zero. Capital gain distributions are on " "Schedule D line 13, so a filer who reports them without Schedule D " "has lines 15 and 16 equal to them." @@ -26,22 +29,11 @@ class has_qdiv_or_ltcg(Variable): ) def formula(tax_unit, period, parameters): - qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) - # Schedule D lines 15 and 16 of the head and spouse; a tax unit - # dependent's gains and losses are on the dependent's own return. - person = tax_unit.members - not_dependent = ~person("is_tax_unit_dependent", period) - distributions = tax_unit.sum( - not_dependent * max_(0, person("non_sch_d_capital_gains", period)) + # Form 1040 line 3a and Schedule D lines 15 and 16 of the head and + # spouse; a tax unit dependent's dividends, gains and losses are on + # the dependent's own return. + qualified_dividends = tax_unit_non_dep_add( + tax_unit, period, ["qualified_dividend_income"] ) - # Line 15: net long-term capital gain or loss, including capital gain - # distributions (line 13). - line_15 = ( - tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) - + distributions - ) - # Line 16: line 15 plus the net short-term gain or loss. - line_16 = line_15 + tax_unit_non_dep_add( - tax_unit, period, ["short_term_capital_gains"] - ) - return (qualified_dividends > 0) | ((line_15 > 0) & (line_16 > 0)) + lines = filer_schedule_d_lines(tax_unit, period) + return (qualified_dividends > 0) | ((lines.line_15 > 0) & (lines.line_16 > 0)) 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 d37d2d6c75c..54c784f8aa2 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 @@ -30,9 +30,13 @@ def formula(tax_unit, period, parameters): # 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. + # the two amounts. As in net_capital_gain, a tax unit dependent's + # dividends are on the dependent's own return. qualified_dividend_income = min_( - max_(0, add(tax_unit, period, ["qualified_dividend_income"])), + max_( + 0, + tax_unit_non_dep_add(tax_unit, period, ["qualified_dividend_income"]), + ), net_capital_gain, ) unrecaptured_s_1250_gain = tax_unit( diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py index efa177d7dd2..e4cb48be68c 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_28_percent_rate_gain.py @@ -12,6 +12,9 @@ def rate_gains_less_losses(tax_unit, period): The same amount is lines 14 to 16 of the Unrecaptured Section 1250 Gain Worksheet combined. Amounts entered already netted, as Schedule D lines 18 and 19 report them, are not part of it. + + The worksheets are the head and spouse's: a tax unit dependent's gains + and losses are on the dependent's own return. """ # These taxpayer-specific worksheets include only the head and spouse's # gains and losses, as on their Schedule D and Form 4952. @@ -23,10 +26,12 @@ def rate_gains_less_losses(tax_unit, period): # 26 U.S.C. 1222(6), the same net short-term capital loss net_capital_gain # subtracts: Schedule D line 7, if a loss. net_short_term_capital_loss = max_( - 0, -tax_unit_non_dep_add(tax_unit, period, ["short_term_capital_gains"]) + 0, + -tax_unit_non_dep_add(tax_unit, period, ["short_term_capital_gains"]), ) carryover = max_( - 0, tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_loss_carryover"]) + 0, + tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_loss_carryover"]), ) return collectibles + section_1202_gain - net_short_term_capital_loss - carryover 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 c1942cc2082..bb0af428d9d 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 @@ -27,8 +27,9 @@ class dividend_income_reduced_by_investment_income(Variable): ] def formula(tax_unit, period, parameters): - # Line 2: qualified dividends (Form 1040 line 3a). - line_2 = add(tax_unit, period, ["qualified_dividend_income"]) + # Line 2: qualified dividends (Form 1040 line 3a), the head and + # spouse's; a tax unit dependent's are on the dependent's own return. + line_2 = tax_unit_non_dep_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 diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py new file mode 100644 index 00000000000..a0e3654534f --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py @@ -0,0 +1,56 @@ +from collections import namedtuple + +from policyengine_us.model_api import * + +FilerScheduleDLines = namedtuple( + "FilerScheduleDLines", ["line_7", "line_15", "line_16"] +) + + +def filer_schedule_d_lines(tax_unit, period): + """Schedule D (Form 1040) lines 7, 15 and 16 of the head and spouse, shared + by the Form 1040 line 16 routing (has_qdiv_or_ltcg), net_capital_gain and + the Schedule D Tax Worksheet. + + Line 7 is the net short-term capital gain or loss. Line 15 is the net + long-term capital gain or loss, including capital gain distributions + (line 13). With component-derived inputs, line 16 is lines 7 and 15 + combined (2025 Schedule D, lines 7, 13, 15 and 16). + + A tax unit dependent's gains, losses and distributions belong on the + dependent's own return, so they are left out, as irs_gross_income leaves + them out of adjusted gross income. Line 16 starts from net_capital_gains, + so a tax unit amount supplied there is kept; that amount is read as + covering every member, and any dependent's person-level gains and losses + are then taken out. An explicit aggregate override can make line 16 + differ from lines 7 and 15 combined, and the worksheet and statutory + net-capital-gain paths can then disagree. + + Capital gain distributions reported without Schedule D + (non_sch_d_capital_gains) are long-term capital gains (26 U.S.C. + 852(b)(3)(B)) and go on line 13 when the filer files Schedule D (2025 + Instructions for Schedule D, "Capital Gain Distributions"). They are Form + 1099-DIV box 2a amounts, which are not negative, so each filer's input is + floored at zero here, as irs_gross_income floors it. + """ + person = tax_unit.members + dependent = person("is_tax_unit_dependent", period) + distributions = tax_unit.sum( + ~dependent * max_(0, person("non_sch_d_capital_gains", period)) + ) + line_7 = tax_unit_non_dep_add(tax_unit, period, ["short_term_capital_gains"]) + line_15 = ( + tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) + + distributions + ) + # Line 16: lines 7 and 15 combined, figured from net_capital_gains so a + # supplied tax unit amount is kept. + dependent_gains = tax_unit.sum( + dependent + * ( + person("long_term_capital_gains", period) + + person("short_term_capital_gains", period) + ) + ) + line_16 = tax_unit("net_capital_gains", period) - dependent_gains + distributions + return FilerScheduleDLines(line_7=line_7, line_15=line_15, line_16=line_16) 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 d6011f76864..d75a8d36ee4 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 @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.filer_schedule_d_lines import ( + filer_schedule_d_lines, +) class net_capital_gain(Variable): @@ -31,18 +34,25 @@ class net_capital_gain(Variable): title="2025 Form 4952, line 4g instructions", href="https://www.irs.gov/pub/irs-prior/f4952--2025.pdf#page=4", ), + dict( + title="2025 Schedule D (Form 1040), lines 7, 13 and 15", + href="https://www.irs.gov/pub/irs-prior/f1040sd--2025.pdf#page=1", + ), + dict( + title="2025 Form 8814, line 4 (a child's income is on the child's own return)", + href="https://www.irs.gov/pub/irs-prior/f8814--2025.pdf#page=1", + ), ] def formula(tax_unit, period, parameters): - # 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, - ["long_term_capital_gains", "non_sch_d_capital_gains"], - ) - short_term_loss = max_(0, -add(tax_unit, period, ["short_term_capital_gains"])) + # The head and spouse's Schedule D. Capital gain distributions, + # including those reported without Schedule D (Form 1040 line 7a), are + # long-term capital gains under IRC 852(b)(3)(B), on line 15. A tax + # unit dependent's gains, losses and dividends are on the dependent's + # own return. + lines = filer_schedule_d_lines(tax_unit, period) + long_term_gain = lines.line_15 + short_term_loss = max_(0, -lines.line_7) # 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 @@ -53,12 +63,16 @@ def formula(tax_unit, period, parameters): # 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"]) + 0, + tax_unit_non_dep_add( + tax_unit, period, ["investment_income_elected_form_4952"] + ), ) gain_elected = min_(election, gain) dividends_elected = election - gain_elected qualified_dividends = max_( 0, - add(tax_unit, period, ["qualified_dividend_income"]) - dividends_elected, + tax_unit_non_dep_add(tax_unit, period, ["qualified_dividend_income"]) + - dividends_elected, ) 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 index f3eb7b9d7c7..0bdbdbf6d05 100644 --- 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 @@ -1,6 +1,9 @@ from collections import namedtuple from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.filer_schedule_d_lines import ( + filer_schedule_d_lines, +) ScheduleDTaxWorksheetForm4952Lines = namedtuple( "ScheduleDTaxWorksheetForm4952Lines", ["line_3", "line_4", "line_7"] @@ -31,22 +34,22 @@ def schedule_d_tax_worksheet_form_4952_lines(tax_unit, period): (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. + + The worksheet and Form 4952 are the head and spouse's: a tax unit + dependent's gains, losses and election are on the dependent's own return. """ - 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 + election = max_( + 0, + tax_unit_non_dep_add(tax_unit, period, ["investment_income_elected_form_4952"]), + ) + lines = filer_schedule_d_lines(tax_unit, period) # Form 4952 line 4d: total gains over total losses, if any. - net_gain = max_(0, schedule_d_line_16) + net_gain = max_(0, lines.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)) + net_capital_gain = max_(0, lines.line_15 - max_(0, -lines.line_7)) return ScheduleDTaxWorksheetForm4952Lines( line_3=election, line_4=min_(net_gain, net_capital_gain), - line_7=min_(schedule_d_line_15, schedule_d_line_16), + line_7=min_(lines.line_15, lines.line_16), ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain_other_than_dividends.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain_other_than_dividends.py index c66190cdf30..6ff53f5835f 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain_other_than_dividends.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain_other_than_dividends.py @@ -10,7 +10,10 @@ def section_911_net_capital_gain_other_than_dividends(tax_unit, period): rounded to single precision, which moved capital_gains_tax by fractions of a cent for filers with nothing excluded. """ - qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) + # The head and spouse's qualified dividends, as in net_capital_gain. + qualified_dividends = tax_unit_non_dep_add( + tax_unit, period, ["qualified_dividend_income"] + ) gain = max_(0, tax_unit("net_capital_gain", period) - qualified_dividends) excess = tax_unit("section_911_capital_gain_excess", period) return where(excess > 0, max_(0, gain - excess), gain) 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 3cfc0d25094..926cb5265d0 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 @@ -36,9 +36,13 @@ def formula(tax_unit, period, parameters): # 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. + # two amounts. A tax unit dependent's dividends are on the + # dependent's own return. qualified_dividends = min_( - max_(0, add(tax_unit, period, ["qualified_dividend_income"])), + max_( + 0, + tax_unit_non_dep_add(tax_unit, period, ["qualified_dividend_income"]), + ), net_capital_gain, ) excess = tax_unit("section_911_capital_gain_excess", period) diff --git a/policyengine_us/variables/gov/states/hi/tax/income/alternative_tax/hi_capital_gain_for_alternative_tax.py b/policyengine_us/variables/gov/states/hi/tax/income/alternative_tax/hi_capital_gain_for_alternative_tax.py index b3c4c9b5ffa..716830512ca 100644 --- a/policyengine_us/variables/gov/states/hi/tax/income/alternative_tax/hi_capital_gain_for_alternative_tax.py +++ b/policyengine_us/variables/gov/states/hi/tax/income/alternative_tax/hi_capital_gain_for_alternative_tax.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.filer_schedule_d_lines import ( + filer_schedule_d_lines, +) class hi_capital_gain_for_alternative_tax(Variable): @@ -17,15 +20,12 @@ class hi_capital_gain_for_alternative_tax(Variable): defined_for = StateCode.HI def formula(tax_unit, period, parameters): - # Lines 2 and 4: federal Schedule D line 15, or Form 1040 line 7 - # if Schedule D is not required. - net_lt_capital_gain = add( - tax_unit, - period, - ["long_term_capital_gains", "non_sch_d_capital_gains"], - ) - # Lines 5 and 7: federal Schedule D line 16 (lines 7 and 15 combined). - net_st_capital_gain = add(tax_unit, period, ["short_term_capital_gains"]) - net_capital_gain = net_lt_capital_gain + net_st_capital_gain + # Lines 2 and 4: the head and spouse's Schedule D line 15, including + # their capital gain distributions, each floored at zero. Dependents' + # amounts belong on their own returns. + lines = filer_schedule_d_lines(tax_unit, period) + # Lines 5 and 7: combine the filers' person-level short- and long-term + # gains, as this worksheet does when deriving Schedule D line 16. + net_capital_gain = lines.line_15 + lines.line_7 # Line 8. Line 10 stops the worksheet if this amount is zero or less. - return max_(0, min_(net_lt_capital_gain, net_capital_gain)) + return max_(0, min_(lines.line_15, net_capital_gain)) diff --git a/policyengine_us/variables/gov/states/vt/tax/income/adjusted_gross_income/subtractions/vt_capital_gain_exclusion/vt_capital_gains_exclusion.py b/policyengine_us/variables/gov/states/vt/tax/income/adjusted_gross_income/subtractions/vt_capital_gain_exclusion/vt_capital_gains_exclusion.py index 0c37696c4cc..f178a6c1207 100644 --- a/policyengine_us/variables/gov/states/vt/tax/income/adjusted_gross_income/subtractions/vt_capital_gain_exclusion/vt_capital_gains_exclusion.py +++ b/policyengine_us/variables/gov/states/vt/tax/income/adjusted_gross_income/subtractions/vt_capital_gain_exclusion/vt_capital_gains_exclusion.py @@ -14,9 +14,13 @@ class vt_capital_gains_exclusion(Variable): ) def formula(tax_unit, period, parameters): - # Get adjusted net capital gains (used for flat exclusion) + # Get adjusted net capital gains (used for flat exclusion). Like the + # federal amount, the qualified dividends taken out of it are the head + # and spouse's; a tax unit dependent's are on the dependent's own return. adjusted_net_capital_gain = tax_unit("adjusted_net_capital_gain", period) - qualified_dividend_income = add(tax_unit, period, ["qualified_dividend_income"]) + qualified_dividend_income = tax_unit_non_dep_add( + tax_unit, period, ["qualified_dividend_income"] + ) reduced_adjusted_net_capital_gain = max_( adjusted_net_capital_gain - qualified_dividend_income, 0 ) diff --git a/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py b/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py index ad4d944c6b7..01dcc3e90fe 100644 --- a/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py +++ b/policyengine_us/variables/household/income/person/capital_gains/non_sch_d_capital_gains.py @@ -12,7 +12,9 @@ class non_sch_d_capital_gains(Variable): "or other capital gains enters them on Schedule D line 13, so the " "model nets them with those gains and losses before the capital loss " "limit (loss_limited_net_capital_gains, " - "capital_losses_allowed_against_gains and limited_capital_loss)." + "capital_losses_allowed_against_gains and limited_capital_loss). " + "Form 1099-DIV box 2a is never negative, so adjusted gross income and " + "the tax on net capital gain treat a negative entry as zero." ) reference = dict( title="2025 Instructions for Schedule D (Form 1040), Capital Gain Distributions",