Mapping 990 Schedule A Public Support Test to JSON

Model the IRS Form 990 Schedule A, Part II public support test as a validated pydantic v2 / JSON schema: Decimal money, the five-year measuring period, the 33-1/3% test, the 10% facts-and-circumstances fallback, and computation lineage.

This guide is part of the IRS 990 Data Schema Mapping section within the broader Core Architecture & Compliance Mapping framework, and it solves one narrow problem: how do you turn the numbers on Schedule A, Part II into a validated JSON contract that computes a defensible public support percentage — same inputs, same fraction, same pass/fail verdict, every run?

The public support test decides whether a 170(b)(1)(A)(vi) organization stays a public charity or tips into private-foundation status, so the arithmetic cannot live in a spreadsheet whose formulas nobody can replay. This guide builds a pydantic v2 model over the five-year measuring period, keeps every dollar in Decimal, computes the fraction the way Schedule A does, applies the one-third threshold with the 10% facts-and-circumstances alternative, and emits a result object that carries its own line-by-line lineage.

When to Use This Approach

Reach for this model when all three conditions hold:

  • The organization files Schedule A, Part II — not Part III. Part II implements the public support test for organizations described in IRS Form 990 Schedule A, Part II (section 170(b)(1)(A)(vi)). The gross-receipts test in Part III (section 509(a)(2)) uses different lines and a different denominator; do not feed Part III figures into this schema. Grantor-specific eligibility rules that key off charity status belong to Grantor-Specific Rule Taxonomies, not here.
  • You need a replayable verdict, not a display number. The public support percentage is a regulated determination: it drives the organization’s own filing and, downstream, a funder’s due-diligence file. Every figure the model touches is recorded in a computation lineage so an examiner can trace line 14 back to the individual columns.
  • Money precision is load-bearing. Rounding a five-year aggregate through binary floating point can move the fraction across the 33-1/3% line. Every monetary field is Decimal, quantized to cents, and negative amounts are rejected at validation time rather than silently flowing into the sum.

Field extraction from the filed PDF, alias resolution across form versions, and officer-compensation modeling are explicitly out of scope. Reading the raw return is the job of the parent IRS 990 Data Schema Mapping stage; the companion reference How to Map IRS 990 Part VII to JSON Schema handles the governance section. This page assumes the Part II line values already arrived as clean numbers and concerns itself only with validating and computing them.

Public support test as a validated computation and two-stage decision gate Five years of Schedule A Part II figures (columns a through e) aggregate into line 4 total and, after subtracting line 5 excess contributions, into line 6 public support and line 11 total support. Line 14 is line 6 divided by line 11. The first gate asks whether the fraction is at least one third: if yes the organization is publicly supported under line 16a. If no, a second gate asks whether the fraction is at least ten percent with a facts-and-circumstances narrative under line 17: if yes it remains a public charity, otherwise it is treated as a private foundation. The public support test as a computation, then two gates Five years aggregate to line 14; the fraction is checked against one-third, then against a 10% facts-and-circumstances fallback. ≥ 33⅓% < 33⅓% ≥ 10% + facts < 10% Schedule A Part II columns (a)–(e), 5 yrs Aggregate & subtract line 4 − line 5 Support % line 14 = 6 ÷ 11 one-third gate? Publicly supported line 16a · 170(b)(1)(A)(vi) 10% + facts? Public charity line 17 facts & circumstances Private foundation fails public support

Step-by-Step Implementation

The reference implementation targets Python 3.10+ and pydantic v2. Keep pydantic pinned to a v2 line — the validation contract below uses field_validator, ConfigDict, and model_dump, none of which behave the same on v1; the migration trade-offs are covered in the companion Pydantic v1 vs v2 Validation Contracts for IRS 990 Ingestion.

text
pydantic==2.7.1

One contract to internalize before writing any code: the fraction, not the rounded percentage, decides the test. Schedule A prints line 14 to two decimals, but the pass/fail comparison is against exactly one-third. Comparing a rounded 33.33 against 33.33 can flip a borderline organization, so the gate below compares the raw Decimal fraction against Decimal(1) / Decimal(3).

Step 1: Model the five-year measuring period

Every column of Schedule A, Part II is one tax year. Model a single year as a frozen pydantic model whose monetary fields are Decimal, then wrap exactly five of them in a measuring-period container. Configure the standard-library audit logger first (never print) so every computation is replayable from its logs.

python
import logging
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, List

from pydantic import BaseModel, ConfigDict, Field, field_validator

AUDIT_LOGGER = logging.getLogger("grant_990.schedule_a")
AUDIT_LOGGER.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
AUDIT_LOGGER.addHandler(_handler)

CENTS = Decimal("0.01")


def _money(value: Decimal) -> Decimal:
    """Quantize to cents with banker-safe half-up rounding."""
    return value.quantize(CENTS, rounding=ROUND_HALF_UP)


class SupportYear(BaseModel):
    """One column (a)-(e) of Schedule A, Part II: a single tax year."""

    model_config = ConfigDict(frozen=True)

    tax_year: int = Field(ge=1970, le=2100)
    # Section A components that feed the line 4 total support base
    line1_gifts_grants_contributions: Decimal = Field(ge=0)
    line2_tax_revenues_levied: Decimal = Field(default=Decimal("0"), ge=0)
    line3_govt_facilities_furnished: Decimal = Field(default=Decimal("0"), ge=0)
    # Section B components counted only in total support
    line8_gross_investment_income: Decimal = Field(default=Decimal("0"), ge=0)
    line9_unrelated_business_income: Decimal = Field(default=Decimal("0"), ge=0)
    line10_other_income: Decimal = Field(default=Decimal("0"), ge=0)

    @field_validator("*", mode="after")
    @classmethod
    def _quantize(cls, value: object) -> object:
        return _money(value) if isinstance(value, Decimal) else value

    @property
    def line4_total(self) -> Decimal:
        return _money(
            self.line1_gifts_grants_contributions
            + self.line2_tax_revenues_levied
            + self.line3_govt_facilities_furnished
        )

The ge=0 constraints reject negative money at parse time, and the wildcard field_validator("*") quantizes every Decimal to cents so no unrounded intermediate can leak into the aggregate. frozen=True makes each year immutable once validated — the audit trail depends on inputs that cannot be mutated after the fact.

Step 2: Define the Decimal-money support schema

Wrap the years in a MeasuringPeriod that enforces the exact five-year window Part II requires and carries line 5 — the portion of any one donor’s gifts exceeding 2% of total support, which is subtracted from the public-support numerator. Validating the count and the distinct years here means the computation in Step 3 can trust its inputs.

python
class MeasuringPeriod(BaseModel):
    """The five-year window: current year plus four prior, columns (a)-(e)."""

    model_config = ConfigDict(frozen=True)

    years: List[SupportYear]
    # line 5, column (f): donor contributions exceeding the 2% ceiling
    line5_excess_contributions: Decimal = Field(default=Decimal("0"), ge=0)

    @field_validator("years", mode="after")
    @classmethod
    def _exactly_five_distinct(cls, years: List[SupportYear]) -> List[SupportYear]:
        if len(years) != 5:
            raise ValueError(
                f"Schedule A Part II requires exactly 5 tax years; received {len(years)}"
            )
        if len({y.tax_year for y in years}) != 5:
            raise ValueError("Measuring period contains duplicate tax years")
        return years

    @field_validator("line5_excess_contributions", mode="after")
    @classmethod
    def _quantize_line5(cls, value: Decimal) -> Decimal:
        return _money(value)

Line 5 defaults to zero because many small organizations have no donor over the 2% ceiling, but when it is present it must never exceed the line 4 base — a guard the computation enforces so public support can never come out negative.

Step 3: Compute public support and total support

Aggregate the columns into the Schedule A line numbers: line 4 is the sum of every year’s total support base, line 6 is line 4 minus line 5, and line 11 is line 4 (carried to line 7) plus the Section B investment, unrelated-business, and other income. Line 14 is the public support percentage. Record each figure and its formula in a lineage dictionary so the verdict is traceable.

python
class SupportComputation(BaseModel):
    line4_total_support_base: Decimal
    line5_excess_contributions: Decimal
    line6_public_support: Decimal
    line11_total_support: Decimal
    line14_public_support_pct: Decimal
    lineage: Dict[str, str]


def compute_support(period: MeasuringPeriod) -> SupportComputation:
    """Fold the five-year columns into the Schedule A Part II line values."""
    line4 = _money(sum((y.line4_total for y in period.years), Decimal("0")))
    if period.line5_excess_contributions > line4:
        raise ValueError(
            "line 5 excess contributions exceed the line 4 base; public support would be negative"
        )
    line6 = _money(line4 - period.line5_excess_contributions)
    section_b_extra = _money(
        sum(
            (
                y.line8_gross_investment_income
                + y.line9_unrelated_business_income
                + y.line10_other_income
                for y in period.years
            ),
            Decimal("0"),
        )
    )
    line11 = _money(line4 + section_b_extra)  # line 7 equals the line 4 aggregate
    if line11 <= 0:
        raise ValueError("total support (line 11) is zero; the fraction is undefined")

    pct = _money((line6 / line11) * Decimal("100"))
    lineage = {
        "line4_total_support_base": f"sum of 5 yr line 4 = {line4}",
        "line6_public_support": f"line 4 {line4} - line 5 {period.line5_excess_contributions} = {line6}",
        "line11_total_support": f"line 4 {line4} + section B {section_b_extra} = {line11}",
        "line14_public_support_pct": f"100 * line 6 {line6} / line 11 {line11} = {pct}",
    }
    AUDIT_LOGGER.info(
        "computed public support | line6=%s line11=%s line14=%s%%", line6, line11, pct
    )
    return SupportComputation(
        line4_total_support_base=line4,
        line5_excess_contributions=period.line5_excess_contributions,
        line6_public_support=line6,
        line11_total_support=line11,
        line14_public_support_pct=pct,
        lineage=lineage,
    )

The explicit zero-denominator guard turns a would-be ZeroDivisionError into a structured ValueError an upstream caller can categorize, and the lineage strings are the exact arithmetic an examiner would redo by hand.

Step 4: Apply the one-third test and the 10 percent fallback

Now gate the fraction. The primary test — IRS Form 990 Schedule A, Part II, line 16a — is met when public support is at least one-third of total support. If it falls short but is still at least 10%, the organization can qualify under the line 17 facts-and-circumstances alternative, which is a narrative determination the code flags rather than decides. Below 10%, it fails.

python
from enum import Enum

ONE_THIRD = Decimal(1) / Decimal(3)
FACTS_FLOOR = Decimal("0.10")


class SupportStatus(str, Enum):
    PUBLICLY_SUPPORTED = "publicly_supported"            # line 16a: one-third test met
    FACTS_AND_CIRCUMSTANCES = "facts_and_circumstances"  # line 17: 10% + narrative
    NOT_PUBLICLY_SUPPORTED = "not_publicly_supported"


class PublicSupportResult(BaseModel):
    status: SupportStatus
    line14_public_support_pct: Decimal
    facts_and_circumstances_required: bool
    computation: SupportComputation


def evaluate_public_support(computation: SupportComputation) -> PublicSupportResult:
    """Apply the 33-1/3% test, then the 10% facts-and-circumstances fallback."""
    fraction = computation.line6_public_support / computation.line11_total_support

    if fraction >= ONE_THIRD:
        status, facts_required = SupportStatus.PUBLICLY_SUPPORTED, False
        AUDIT_LOGGER.info("line 16a met: %s%% >= 33-1/3%%", computation.line14_public_support_pct)
    elif fraction >= FACTS_FLOOR:
        status, facts_required = SupportStatus.FACTS_AND_CIRCUMSTANCES, True
        AUDIT_LOGGER.warning(
            "line 16a not met; line 17 available at %s%% - facts-and-circumstances review required",
            computation.line14_public_support_pct,
        )
    else:
        status, facts_required = SupportStatus.NOT_PUBLICLY_SUPPORTED, False
        AUDIT_LOGGER.warning(
            "public support %s%% below 10%%: private-foundation treatment",
            computation.line14_public_support_pct,
        )

    return PublicSupportResult(
        status=status,
        line14_public_support_pct=computation.line14_public_support_pct,
        facts_and_circumstances_required=facts_required,
        computation=computation,
    )

Comparing fraction against Decimal(1) / Decimal(3) rather than the rounded 33.33 is what keeps a 33.34%-vs-33.33% boundary case correct. The FACTS_AND_CIRCUMSTANCES status is deliberately not a pass — it signals that a human must supply the line 17 narrative before the determination is final.

Step 5: Emit a validated result with computation lineage

Tie the stages together and serialize. model_dump(mode="json") renders every Decimal as a JSON string, so no precision is lost crossing the wire, and the embedded lineage travels with the verdict into whatever ledger or evidence store consumes it.

python
import json


def run_public_support_test(period: MeasuringPeriod) -> str:
    """Validate, compute, evaluate, and emit a JSON result with lineage."""
    computation = compute_support(period)
    result = evaluate_public_support(computation)
    payload = result.model_dump(mode="json")
    AUDIT_LOGGER.info("public support verdict: %s", result.status.value)
    return json.dumps(payload, indent=2)

Because the entire pipeline runs on immutable, validated inputs, the emitted JSON is a self-contained artifact: an auditor can re-parse it, recompute line 14 from the lineage, and reach the same status without the original workbook.

Verification

Confirm the model behaves correctly with four checks:

  1. The one-third test passes cleanly above threshold. Build a period whose public support is 40% of total support and assert status == SupportStatus.PUBLICLY_SUPPORTED and facts_and_circumstances_required is False.
  2. The 10% band routes to facts-and-circumstances. Build a period landing at, say, 22% and assert the status is FACTS_AND_CIRCUMSTANCES and the flag is True — proof the code never auto-passes line 17.
  3. Negative and malformed inputs are rejected. Assert that a negative line1_gifts_grants_contributions raises a pydantic ValidationError, and that four years (not five) raises one too.
  4. The lineage reproduces line 14. Recompute 100 * line6 / line11 from the lineage dictionary and assert it equals line14_public_support_pct to the cent.

A passing run emits one INFO audit line carrying line 6, line 11, and line 14; a facts-and-circumstances or failing run emits a WARNING. Ship those logs to a write-once tier so the determination trail is replayable.

python
period = MeasuringPeriod(
    years=[
        SupportYear(tax_year=y, line1_gifts_grants_contributions=Decimal("400000"),
                    line8_gross_investment_income=Decimal("50000"))
        for y in range(2021, 2026)
    ],
)
result = evaluate_public_support(compute_support(period))
assert result.status is SupportStatus.PUBLICLY_SUPPORTED
recomputed = (result.computation.line6_public_support
              / result.computation.line11_total_support * Decimal("100")).quantize(CENTS)
assert recomputed == result.line14_public_support_pct

Common Errors & Fixes

Error Cause Fix
Borderline organization flips pass/fail Comparing the rounded 33.33 percentage instead of the fraction Gate on fraction >= Decimal(1) / Decimal(3); keep the rounded percentage for display only.
ValidationError on parse A monetary field is negative or the period has fewer than five years Correct the upstream extraction; ge=0 and the five-year validator are contract guards, not bugs.
ValueError: total support (line 11) is zero Every Section A and Section B line is zero or missing Confirm the return actually carries Part II data; an all-zero period cannot yield a fraction.
Public support comes out negative Line 5 excess contributions exceed the line 4 base The model raises before dividing; recheck the 2% donor computation feeding line 5.
Precision drift across a 5-year sum Money parsed as float before reaching the model Keep values as strings or Decimal end to end; the wildcard validator quantizes but cannot recover lost float precision.