From 18a9a0fd2276aae4b529895629f10d02b42745d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 22 Feb 2026 19:29:29 -0500 Subject: [PATCH 1/2] Split charitable deduction AGI cap by organization type Implements 26 USC 170(b)(1)(A)-(B) distinction for non-cash charitable donations. Non-cash donations to 50-percent limit organizations (churches, hospitals, educational institutions, etc.) remain capped at 50% of AGI, while donations to other organizations (e.g., private foundations) are now correctly capped at 30% of AGI. Adds: - New input variable: charitable_non_cash_donations_to_non_50_percent_limit_orgs - New parameter: ceiling/non_cash_to_non_50_pct_org (30% cap) - Updated formula with separate caps per org type - 5 new test cases covering the split behavior Fixes PolicyEngine/policyengine-us#6418 Co-Authored-By: Claude Opus 4.6 --- changelog_entry.yaml | 4 ++ .../ceiling/non_cash_to_non_50_pct_org.yaml | 10 +++ .../itemizing/charitable_deduction.yaml | 70 +++++++++++++++++++ .../itemizing/charitable_deduction.py | 47 ++++++++++--- ..._donations_to_non_50_percent_limit_orgs.py | 17 +++++ 5 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml create mode 100644 policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_to_non_50_percent_limit_orgs.py diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29bb2d..a143721aae2 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,4 @@ +- bump: minor + changes: + added: + - Split non-cash charitable deduction AGI cap by organization type per 26 USC 170(b)(1)(A)-(B). Non-cash donations to 50-percent limit organizations are capped at 50% of AGI, while donations to other organizations are capped at 30% of AGI. diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml new file mode 100644 index 00000000000..b6127822bbf --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml @@ -0,0 +1,10 @@ +description: Ceiling (as a fraction of AGI) for noncash charitable contributions to organizations other than 50-percent limit organizations. +metadata: + unit: /1 + period: year + label: Non-cash charitable deduction limit for non-50% limit organizations + reference: + - title: 26 U.S. Code § 170 - Charitable, etc., contributions and gifts (b)(1)(B) + href: https://www.law.cornell.edu/uscode/text/26/170#b_1_B +values: + 2013-01-01: 0.3 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml index 74d9406b340..a14f9183f16 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml @@ -91,3 +91,73 @@ positive_agi: 20_000 output: charitable_deduction: 12_000 + +- name: Non-cash to non-50% limit orgs capped at 30% of AGI + period: 2024 + input: + charitable_cash_donations: 0 + charitable_non_cash_donations: 500 + charitable_non_cash_donations_to_non_50_percent_limit_orgs: 500 + positive_agi: 1_000 + output: + # 50% org non-cash = 0 + # non-50% org: min(500, 0.3 * 1000) = 300 + # min(0 + 300, 0.6 * 1000) = 300 + charitable_deduction: 300 + +- name: Mix of 50% and non-50% org non-cash donations + period: 2024 + input: + charitable_cash_donations: 0 + charitable_non_cash_donations: 600 + charitable_non_cash_donations_to_non_50_percent_limit_orgs: 200 + positive_agi: 1_000 + output: + # 50% org: min(400, 500) = 400 + # non-50% org: min(200, 300) = 200 + # min(400 + 200, 600) = 600 + charitable_deduction: 600 + +- name: Overall ceiling binds with mixed org types + period: 2024 + input: + charitable_cash_donations: 0 + charitable_non_cash_donations: 800 + charitable_non_cash_donations_to_non_50_percent_limit_orgs: 400 + positive_agi: 1_000 + output: + # 50% org: min(400, 500) = 400 + # non-50% org: min(400, 300) = 300 + # min(400 + 300, 600) = 600 + charitable_deduction: 600 + +- name: Non-50% org only hits 30% cap + period: 2024 + input: + charitable_cash_donations: 0 + charitable_non_cash_donations: 400 + charitable_non_cash_donations_to_non_50_percent_limit_orgs: 400 + positive_agi: 1_000 + output: + # non-50% org: min(400, 300) = 300 + # min(300, 600) = 300 + charitable_deduction: 300 + +- name: Non-50% org with floor and cash + period: 2026 + input: + charitable_cash_donations: 5_000 + charitable_non_cash_donations: 20_000 + charitable_non_cash_donations_to_non_50_percent_limit_orgs: 5_000 + positive_agi: 100_000 + output: + # floor = 0.005 * 100000 = 500 + # 50% org non-cash = 15000, reduced = max(15000 - 500, 0) = 14500 + # capped 50% = min(14500, 50000) = 14500 + # remaining_floor_after_50 = max(500 - 15000, 0) = 0 + # non-50% non-cash = 5000, reduced = max(5000 - 0, 0) = 5000 + # capped 30% = min(5000, 30000) = 5000 + # remaining_floor = max(0 - 5000, 0) = 0 + # cash = max(5000 - 0, 0) = 5000 + # min(14500 + 5000 + 5000, 60000) = 24500 + charitable_deduction: 24_500 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py index e18550282ec..4003ec0abfd 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py @@ -15,26 +15,55 @@ def formula(tax_unit, period, parameters): non_cash_donations = add( tax_unit, period, ["charitable_non_cash_donations"] ) + non_cash_to_non_50_pct = add( + tax_unit, + period, + ["charitable_non_cash_donations_to_non_50_percent_limit_orgs"], + ) + non_cash_to_50_pct = non_cash_donations - non_cash_to_non_50_pct positive_agi = tax_unit("positive_agi", period) p = parameters(period).gov.irs.deductions.itemized.charity - capped_non_cash_donations = min_( - non_cash_donations, p.ceiling.non_cash * positive_agi + + # Cap non-cash to 50% limit orgs at 50% of AGI per + # 26 USC 170(b)(1)(A). + capped_non_cash_50 = min_( + non_cash_to_50_pct, p.ceiling.non_cash * positive_agi ) + # Cap non-cash to non-50% limit orgs at 30% of AGI per + # 26 USC 170(b)(1)(B). + capped_non_cash_30 = min_( + non_cash_to_non_50_pct, + p.ceiling.non_cash_to_non_50_pct_org * positive_agi, + ) + capped_non_cash = capped_non_cash_50 + capped_non_cash_30 total_cap = p.ceiling.all * positive_agi if p.floor.applies: deduction_floor = p.floor.amount * positive_agi - reduced_non_cash_donations = max_( - non_cash_donations - deduction_floor, 0 + reduced_non_cash_50 = max_(non_cash_to_50_pct - deduction_floor, 0) + capped_reduced_non_cash_50 = min_( + reduced_non_cash_50, p.ceiling.non_cash * positive_agi ) - capped_reduced_non_cash_donations = min_( - reduced_non_cash_donations, p.ceiling.non_cash * positive_agi + + remaining_floor_after_50 = max_( + deduction_floor - non_cash_to_50_pct, 0 + ) + reduced_non_cash_30 = max_( + non_cash_to_non_50_pct - remaining_floor_after_50, 0 + ) + capped_reduced_non_cash_30 = min_( + reduced_non_cash_30, + p.ceiling.non_cash_to_non_50_pct_org * positive_agi, ) - remaining_floor = max_(deduction_floor - non_cash_donations, 0) + remaining_floor = max_( + remaining_floor_after_50 - non_cash_to_non_50_pct, 0 + ) reduced_cash_donations = max_(cash_donations - remaining_floor, 0) return min_( - capped_reduced_non_cash_donations + reduced_cash_donations, + capped_reduced_non_cash_50 + + capped_reduced_non_cash_30 + + reduced_cash_donations, total_cap, ) - return min_(capped_non_cash_donations + cash_donations, total_cap) + return min_(capped_non_cash + cash_donations, total_cap) diff --git a/policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_to_non_50_percent_limit_orgs.py b/policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_to_non_50_percent_limit_orgs.py new file mode 100644 index 00000000000..1521aa6ed0c --- /dev/null +++ b/policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_to_non_50_percent_limit_orgs.py @@ -0,0 +1,17 @@ +from policyengine_us.model_api import * + + +class charitable_non_cash_donations_to_non_50_percent_limit_orgs(Variable): + value_type = float + entity = Person + label = "Charitable non-cash donations to non-50% limit organizations" + unit = USD + documentation = ( + "Non-cash charitable donations to organizations other than " + "50-percent limit organizations described in 26 USC 170(b)(1)(A), " + "such as private non-operating foundations. These donations are " + "subject to a 30% AGI ceiling under 26 USC 170(b)(1)(B), rather " + "than the 50% ceiling for 50-percent limit organizations." + ) + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/170#b_1_B" From 4ac84f6945bfd1e0cc54d649f74187ea4bcbd7fd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Feb 2026 06:34:28 -0500 Subject: [PATCH 2/2] Rename variable, add TODOs for full 170(b)(1) category structure - Rename charitable_non_cash_donations_to_non_50_percent_limit_orgs to charitable_non_cash_donations_non_50_pct_orgs (shorter name) - Add TODO comments documenting the full 4-category structure under 26 USC 170(b)(1): cash 60%/30%, non-cash 50%/30% (or 20% for capital gain property to non-50% orgs per 170(b)(1)(D)) - Add notes that current 50% cap models the basis-reduction election under 170(b)(1)(C)(iii), and 30% cap uses the general limit from 170(b)(1)(B) rather than the stricter 20% from 170(b)(1)(D) - Fix pre-existing incorrect test comments (non_cash ceiling is 0.5, not 0.3) - Update parameter YAML with dual references to (B) and (D) Co-Authored-By: Claude Opus 4.6 --- changelog_entry.yaml | 2 +- .../ceiling/non_cash_to_non_50_pct_org.yaml | 10 ++++- .../itemizing/charitable_deduction.yaml | 20 +++++----- .../itemizing/charitable_deduction.py | 39 +++++++++++++------ ...ble_non_cash_donations_non_50_pct_orgs.py} | 10 ++--- 5 files changed, 51 insertions(+), 30 deletions(-) rename policyengine_us/variables/household/expense/charitable/{charitable_non_cash_donations_to_non_50_percent_limit_orgs.py => charitable_non_cash_donations_non_50_pct_orgs.py} (50%) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index a143721aae2..2f12b7dc738 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,4 +1,4 @@ - bump: minor changes: added: - - Split non-cash charitable deduction AGI cap by organization type per 26 USC 170(b)(1)(A)-(B). Non-cash donations to 50-percent limit organizations are capped at 50% of AGI, while donations to other organizations are capped at 30% of AGI. + - Split non-cash charitable deduction AGI cap by organization type per 26 USC 170(b)(1). Non-cash donations to 50-percent limit organizations are capped at 50% of AGI, while donations to other organizations are capped at 30% of AGI. diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml index b6127822bbf..0b96ecd97c3 100644 --- a/policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/charity/ceiling/non_cash_to_non_50_pct_org.yaml @@ -1,10 +1,16 @@ -description: Ceiling (as a fraction of AGI) for noncash charitable contributions to organizations other than 50-percent limit organizations. +description: >- + Ceiling (as a fraction of AGI) for noncash charitable contributions + to organizations other than 50-percent limit organizations. + Note: 170(b)(1)(D) sets a stricter 20% cap for capital gain property + to these organizations, which is not yet modeled separately. metadata: unit: /1 period: year label: Non-cash charitable deduction limit for non-50% limit organizations reference: - - title: 26 U.S. Code § 170 - Charitable, etc., contributions and gifts (b)(1)(B) + - title: 26 U.S. Code 170(b)(1)(B) - General 30% limit for non-50% limit organizations href: https://www.law.cornell.edu/uscode/text/26/170#b_1_B + - title: 26 U.S. Code 170(b)(1)(D) - 20% limit for capital gain property to non-50% limit organizations + href: https://www.law.cornell.edu/uscode/text/26/170#b_1_D values: 2013-01-01: 0.3 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml index a14f9183f16..4d0ee38f078 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.yaml @@ -5,7 +5,7 @@ charitable_non_cash_donations: 500 positive_agi: 1_000 charitable_deduction_for_non_itemizers: 0 - output: # min(min(500, 1000 * 0.3) + 300, 1000 * 0.5) + output: # min(min(500, 1000 * 0.5) + 300, 1000 * 0.5) charitable_deduction: 500 - name: 2018 filer @@ -14,7 +14,7 @@ charitable_cash_donations: 500 charitable_non_cash_donations: 200 positive_agi: 1_000 - output: # min(min(200, 1000 * 0.3) + 500, 1000 * 0.6) + output: # min(min(200, 1000 * 0.5) + 500, 1000 * 0.6) charitable_deduction: 600 - name: 2020 filer @@ -24,7 +24,7 @@ charitable_non_cash_donations: 200 positive_agi: 1_000 charitable_deduction_for_non_itemizers: 0 - output: # min(min(200, 1000 * 0.3) + 500, 1000 * 1) + output: # min(min(200, 1000 * 0.5) + 500, 1000 * 1) charitable_deduction: 700 - name: 2021 filer @@ -34,7 +34,7 @@ charitable_non_cash_donations: 200 positive_agi: 1_000 charitable_deduction_for_non_itemizers: 300 - output: # min(min(200, 1000 * 0.3) + (500 - 300), 1000 * 0.6) + output: # min(min(200, 1000 * 0.5) + (500 - 300), 1000 * 0.6) charitable_deduction: 600 - name: 2021 filer 2 @@ -44,7 +44,7 @@ charitable_non_cash_donations: 200 positive_agi: 1_000 charitable_deduction_for_non_itemizers: 600 - output: # min(min(200, 1000 * 0.3) + min(500 - 600, 0), 1000 * 0.6) + output: # min(min(200, 1000 * 0.5) + max(500 - 600, 0), 1000 * 0.6) charitable_deduction: 600 - name: No AGI @@ -97,7 +97,7 @@ input: charitable_cash_donations: 0 charitable_non_cash_donations: 500 - charitable_non_cash_donations_to_non_50_percent_limit_orgs: 500 + charitable_non_cash_donations_non_50_pct_orgs: 500 positive_agi: 1_000 output: # 50% org non-cash = 0 @@ -110,7 +110,7 @@ input: charitable_cash_donations: 0 charitable_non_cash_donations: 600 - charitable_non_cash_donations_to_non_50_percent_limit_orgs: 200 + charitable_non_cash_donations_non_50_pct_orgs: 200 positive_agi: 1_000 output: # 50% org: min(400, 500) = 400 @@ -123,7 +123,7 @@ input: charitable_cash_donations: 0 charitable_non_cash_donations: 800 - charitable_non_cash_donations_to_non_50_percent_limit_orgs: 400 + charitable_non_cash_donations_non_50_pct_orgs: 400 positive_agi: 1_000 output: # 50% org: min(400, 500) = 400 @@ -136,7 +136,7 @@ input: charitable_cash_donations: 0 charitable_non_cash_donations: 400 - charitable_non_cash_donations_to_non_50_percent_limit_orgs: 400 + charitable_non_cash_donations_non_50_pct_orgs: 400 positive_agi: 1_000 output: # non-50% org: min(400, 300) = 300 @@ -148,7 +148,7 @@ input: charitable_cash_donations: 5_000 charitable_non_cash_donations: 20_000 - charitable_non_cash_donations_to_non_50_percent_limit_orgs: 5_000 + charitable_non_cash_donations_non_50_pct_orgs: 5_000 positive_agi: 100_000 output: # floor = 0.005 * 100000 = 500 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py index 4003ec0abfd..c55323b7c7c 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/charitable_deduction.py @@ -11,6 +11,16 @@ class charitable_deduction(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/170" def formula(tax_unit, period, parameters): + # TODO: 26 USC 170(b)(1) defines 4 contribution categories with + # separate AGI caps: + # (G) Cash to 50% limit orgs: 60% AGI (post-TCJA) + # (B) Cash to non-50% limit orgs: 30% AGI + # (C) Capital gain property to 50% limit orgs: 30% AGI + # (or 50% with basis-reduction election) + # (D) Capital gain property to non-50% limit orgs: 20% AGI + # Currently we model two non-cash categories (50% vs 30% cap) + # and treat all cash as subject to the overall cap only. + # The cash split and the 20% category are not yet modeled. cash_donations = add(tax_unit, period, ["charitable_cash_donations"]) non_cash_donations = add( tax_unit, period, ["charitable_non_cash_donations"] @@ -18,41 +28,46 @@ def formula(tax_unit, period, parameters): non_cash_to_non_50_pct = add( tax_unit, period, - ["charitable_non_cash_donations_to_non_50_percent_limit_orgs"], + ["charitable_non_cash_donations_non_50_pct_orgs"], ) non_cash_to_50_pct = non_cash_donations - non_cash_to_non_50_pct positive_agi = tax_unit("positive_agi", period) p = parameters(period).gov.irs.deductions.itemized.charity - # Cap non-cash to 50% limit orgs at 50% of AGI per - # 26 USC 170(b)(1)(A). + # Non-cash to 50% limit orgs capped at 50% of AGI. + # Note: this models the basis-reduction election under + # 170(b)(1)(C)(iii); the default cap for capital gain + # property is 30% per 170(b)(1)(C)(i). capped_non_cash_50 = min_( non_cash_to_50_pct, p.ceiling.non_cash * positive_agi ) - # Cap non-cash to non-50% limit orgs at 30% of AGI per - # 26 USC 170(b)(1)(B). - capped_non_cash_30 = min_( + # Non-cash to non-50% limit orgs capped at 30% of AGI + # per 170(b)(1)(B). Note: the stricter 20% cap for + # capital gain property under 170(b)(1)(D) is not yet + # modeled separately. + capped_non_cash_non_50 = min_( non_cash_to_non_50_pct, p.ceiling.non_cash_to_non_50_pct_org * positive_agi, ) - capped_non_cash = capped_non_cash_50 + capped_non_cash_30 + capped_non_cash = capped_non_cash_50 + capped_non_cash_non_50 total_cap = p.ceiling.all * positive_agi if p.floor.applies: deduction_floor = p.floor.amount * positive_agi reduced_non_cash_50 = max_(non_cash_to_50_pct - deduction_floor, 0) capped_reduced_non_cash_50 = min_( - reduced_non_cash_50, p.ceiling.non_cash * positive_agi + reduced_non_cash_50, + p.ceiling.non_cash * positive_agi, ) remaining_floor_after_50 = max_( deduction_floor - non_cash_to_50_pct, 0 ) - reduced_non_cash_30 = max_( + reduced_non_cash_non_50 = max_( non_cash_to_non_50_pct - remaining_floor_after_50, 0 ) - capped_reduced_non_cash_30 = min_( - reduced_non_cash_30, + capped_reduced_non_cash_non_50 = min_( + reduced_non_cash_non_50, p.ceiling.non_cash_to_non_50_pct_org * positive_agi, ) @@ -62,7 +77,7 @@ def formula(tax_unit, period, parameters): reduced_cash_donations = max_(cash_donations - remaining_floor, 0) return min_( capped_reduced_non_cash_50 - + capped_reduced_non_cash_30 + + capped_reduced_non_cash_non_50 + reduced_cash_donations, total_cap, ) diff --git a/policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_to_non_50_percent_limit_orgs.py b/policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_non_50_pct_orgs.py similarity index 50% rename from policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_to_non_50_percent_limit_orgs.py rename to policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_non_50_pct_orgs.py index 1521aa6ed0c..a33bea313d5 100644 --- a/policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_to_non_50_percent_limit_orgs.py +++ b/policyengine_us/variables/household/expense/charitable/charitable_non_cash_donations_non_50_pct_orgs.py @@ -1,17 +1,17 @@ from policyengine_us.model_api import * -class charitable_non_cash_donations_to_non_50_percent_limit_orgs(Variable): +class charitable_non_cash_donations_non_50_pct_orgs(Variable): value_type = float entity = Person label = "Charitable non-cash donations to non-50% limit organizations" unit = USD documentation = ( "Non-cash charitable donations to organizations other than " - "50-percent limit organizations described in 26 USC 170(b)(1)(A), " - "such as private non-operating foundations. These donations are " - "subject to a 30% AGI ceiling under 26 USC 170(b)(1)(B), rather " - "than the 50% ceiling for 50-percent limit organizations." + "50-percent limit organizations described in " + "26 USC 170(b)(1)(A), such as private non-operating " + "foundations. These are subject to a lower AGI ceiling " + "than donations to 50-percent limit organizations." ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/170#b_1_B"