This guide is part of the State Charity Registration Compliance section within the broader Core Architecture & Compliance Mapping framework, and it solves one narrow problem: a nonprofit that solicits donations in a dozen or more states has a dozen or more renewal deadlines, most of them keyed off the organization’s fiscal year-end rather than a shared calendar date — and missing one means soliciting illegally in that state.
The naive answer — a spreadsheet with a hand-typed date per state — rots the moment the organization changes its fiscal year, adds a state, or hits a leap year. This guide computes each deadline from the org’s fiscal year-end (FYE) with a typed function, models every state’s rule as data rather than code, emits a consolidated renewal calendar, fires tiered reminders at 90/60/30/7 days out, and sets a machine-readable solicitation_blocked flag the moment a registration lapses.
When to Use This Approach
Reach for this tracker when all three conditions hold:
- You solicit in multiple states. Every state with a charitable-solicitation statute requires an out-of-state nonprofit to register with the state Attorney General or equivalent charity official before asking its residents for money, and then to renew that registration on a recurring cycle. New York accepts the CHAR500 annual filing through the Attorney General’s Charities Bureau under Article 7-A of the Executive Law; California requires the RRF-1 Annual Registration Renewal Report filed with the Attorney General’s Registry of Charitable Trusts under Government Code section 12586. Both are due four and a half months after the organization’s fiscal year-end. If you solicit in only one state, a single calendar reminder is enough — you do not need this machinery.
- Deadlines derive from your fiscal year-end, not a fixed date. The majority of states compute the renewal deadline as an offset from FYE (commonly 4.5 months, mirroring the federal Form 990 due date, sometimes with an available extension). A handful use a fixed calendar date or a registration-anniversary date instead. Because the offset dominates, the correct data model is “months after FYE,” and changing the org’s FYE must recompute every derived deadline automatically.
- A missed renewal has to stop outbound solicitation, not just page someone. A lapsed registration is not a paperwork nuisance; soliciting in a state where your registration has lapsed is unlawful solicitation. The tracker’s job is to raise a flag that downstream systems — email campaigns, donation forms, a fundraising CRM — read and honor.
Filing the forms themselves, e-signature, fee payment, and the underlying IRS Form 990 data that many states attach to the renewal are explicitly out of scope here. The full inventory of what each state demands — thresholds, exemptions, required attachments — lives in the companion State-by-State Nonprofit Reporting Requirements Checklist. This page computes and tracks the dates.
Step-by-Step Implementation
The reference implementation targets Python 3.10+ and uses python-dateutil for calendar-correct month arithmetic — plain timedelta cannot add “4.5 months” across variable month lengths, but relativedelta can.
python-dateutil==2.9.0.post0
Step 1: Model each state’s renewal rule as data
Never encode a deadline as an if state == "NY" branch — rules change, and branches are unauditable. Represent every state’s rule as an immutable record: an offset in whole and half months after FYE, or a fixed calendar date; a grace period; and the canonical form name. A RuleKind enum makes the two rule shapes explicit so the compute step in Step 2 can dispatch on it.
import logging
from dataclasses import dataclass
from datetime import date
from enum import Enum
from typing import Dict, Optional
logging.basicConfig(format="%(asctime)s | %(levelname)s | %(message)s")
LOGGER = logging.getLogger("charity_renewal.tracker")
LOGGER.setLevel(logging.INFO)
class RuleKind(Enum):
FYE_OFFSET = "fye_offset" # due N months after fiscal year-end
FIXED_DATE = "fixed_date" # due on a fixed (month, day) each year
@dataclass(frozen=True)
class StateRule:
"""One state's charitable-registration renewal rule, modeled as data."""
state: str
form_name: str
kind: RuleKind
offset_months: float = 0.0 # used when kind is FYE_OFFSET
fixed_month: Optional[int] = None # used when kind is FIXED_DATE
fixed_day: Optional[int] = None
grace_days: int = 0 # days after due date before lapse
STATE_RULES: Dict[str, StateRule] = {
"NY": StateRule("NY", "CHAR500", RuleKind.FYE_OFFSET, offset_months=4.5, grace_days=180),
"CA": StateRule("CA", "RRF-1", RuleKind.FYE_OFFSET, offset_months=4.5, grace_days=0),
"MI": StateRule("MI", "CTS-02", RuleKind.FYE_OFFSET, offset_months=5.5, grace_days=0),
}
The offset_months=4.5 values encode the CHAR500 and RRF-1 “four months and fifteen days after FYE” rule directly. grace_days captures New York’s routine six-month extension while California’s zero-day grace reflects that its renewal is delinquent the day after it is due.
Step 2: Compute the next due date per state from the FYE
Apply each rule to the organization’s fiscal year-end with a single typed function. relativedelta(months=4) handles the whole months; the half-month is expressed as relativedelta(days=15), which is exactly how the statutes read (“four months and fifteen days”). Raise a structured error on an unknown rule kind rather than silently returning None.
from dateutil.relativedelta import relativedelta
class RuleError(ValueError):
"""Raised when a state rule cannot be resolved to a concrete due date."""
def compute_due_date(rule: StateRule, fye: date) -> date:
"""Resolve a state's renewal due date for the cycle ending at `fye`."""
if rule.kind is RuleKind.FYE_OFFSET:
whole = int(rule.offset_months)
half_days = 15 if (rule.offset_months - whole) >= 0.5 else 0
due = fye + relativedelta(months=whole, days=half_days)
elif rule.kind is RuleKind.FIXED_DATE:
if rule.fixed_month is None or rule.fixed_day is None:
raise RuleError(f"{rule.state}: FIXED_DATE rule missing month/day")
due = date(fye.year + 1, rule.fixed_month, rule.fixed_day)
else:
raise RuleError(f"{rule.state}: unsupported rule kind {rule.kind!r}")
LOGGER.info("%s %s due %s (FYE %s)", rule.state, rule.form_name, due, fye)
return due
Passing the date type through end to end means a caller cannot accidentally hand in a string; the leap-year edge (a June 30 FYE never lands on February 29) is handled by relativedelta without special-casing.
Step 3: Generate a consolidated renewal calendar
Fan the org’s single FYE across every registered state to produce one sorted calendar. Each entry carries the state, form, computed due date, and the lapse date (due date plus grace). This is the artifact the scheduler and the CRM both read.
@dataclass(frozen=True)
class RenewalEntry:
state: str
form_name: str
due_date: date
lapse_date: date
def build_calendar(fye: date, registered_states: list[str]) -> list[RenewalEntry]:
"""Compute a sorted renewal calendar across all registered states."""
calendar: list[RenewalEntry] = []
for state in registered_states:
rule = STATE_RULES.get(state)
if rule is None:
LOGGER.error("No rule registered for state %s; skipping", state)
continue
due = compute_due_date(rule, fye)
lapse = due + relativedelta(days=rule.grace_days)
calendar.append(RenewalEntry(state, rule.form_name, due, lapse))
calendar.sort(key=lambda e: e.due_date)
LOGGER.info("Built calendar for %d states", len(calendar))
return calendar
Sorting by due_date means the earliest obligation is always first, so a human scanning the calendar sees the next thing that will bite. An unregistered state is logged and skipped, never silently invented.
Step 4: Schedule tiered alerts at 90/60/30/7 days out
For each entry, emit reminders at descending horizons so the finance team gets an early heads-up and an urgent final nudge. Compare against a reference today (injected, so the function is testable and deterministic) and yield only the alerts that are due now.
from typing import Iterator
ALERT_TIERS: tuple[int, ...] = (90, 60, 30, 7)
def due_alerts(entry: RenewalEntry, today: date) -> Iterator[tuple[int, str]]:
"""Yield (tier, message) for each alert horizon reached for this entry."""
for tier in ALERT_TIERS:
trigger_on = entry.due_date - relativedelta(days=tier)
if trigger_on <= today < entry.due_date:
days_left = (entry.due_date - today).days
msg = (f"{entry.state} {entry.form_name} due {entry.due_date} "
f"({days_left} days left)")
LOGGER.warning("ALERT T-%d: %s", tier, msg)
yield tier, msg
The trigger_on <= today < entry.due_date window makes each tier fire once the horizon is crossed and stops firing after the due date, at which point Step 5’s lapse logic takes over. Wire the yielded messages into whatever notification transport you use; the tracker stays transport-agnostic.
Step 5: Set the lapse flag that blocks solicitation
Once the lapse date passes without a recorded filing confirmation, the state is delinquent and outbound solicitation there is unlawful. Compute a per-state block map that a donation form or campaign filter consults before it ever renders a “Donate” button to a resident of that state.
def solicitation_block_map(
calendar: list[RenewalEntry],
filed_confirmations: Dict[str, date],
today: date,
) -> Dict[str, bool]:
"""Return {state: blocked}; True means solicitation is unlawful there now."""
blocked: Dict[str, bool] = {}
for entry in calendar:
confirmed_on = filed_confirmations.get(entry.state)
is_lapsed = today > entry.lapse_date and (
confirmed_on is None or confirmed_on < entry.due_date
)
blocked[entry.state] = is_lapsed
if is_lapsed:
LOGGER.error(
"LAPSED: %s %s past lapse date %s — solicitation blocked",
entry.state, entry.form_name, entry.lapse_date,
)
return blocked
A state clears the flag only when filed_confirmations holds a confirmation dated on or after the current cycle’s due date — an old confirmation from last year does not satisfy this year’s obligation. The returned map is the contract downstream systems honor; the tracker never solicits, it only reports.
Verification
Confirm the tracker behaves deterministically with these checks:
- The offset lands on the statutory date. For a June 30 FYE, assert
compute_due_date(STATE_RULES["NY"], date(2026, 6, 30))returnsdate(2026, 11, 15)— four months and fifteen days later — matching the CHAR500 and RRF-1 statutory deadline. - Tiered alerts fire once per horizon. With
todayset 60 days before a due date, assertdue_alertsyields the 90- and 60-day tiers but not the 30- or 7-day tiers, proving the window logic is inclusive at the trigger and exclusive after the due date. - A stale confirmation does not clear the block. Provide a
filed_confirmationsentry dated before the current due date and atodaypast the lapse date, and assert the state’s block flag isTrue. - The calendar is sorted and complete. Assert
build_calendarreturns entries in ascendingdue_dateorder and that an unregistered state code produces a logged error, not a silent gap.
fye = date(2026, 6, 30)
calendar = build_calendar(fye, ["NY", "CA", "MI"])
assert calendar[0].due_date <= calendar[-1].due_date # sorted
assert compute_due_date(STATE_RULES["CA"], fye) == date(2026, 11, 15)
blocked = solicitation_block_map(calendar, {}, today=date(2027, 6, 1))
assert blocked["CA"] is True # lapsed, no filing
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
| Due date off by a few days across months | Using timedelta(days=135) to approximate “4.5 months” |
Use relativedelta(months=4, days=15); calendar months vary in length and timedelta cannot express them. |
RuleError: unsupported rule kind |
A new state was added with a rule shape the compute step does not handle | Add a branch in compute_due_date for the new RuleKind; keep the raise so unknown kinds fail loudly. |
| A state stays blocked after filing | filed_confirmations holds a date before the current due date, or no date at all |
Record the confirmation with a date on or after this cycle’s due date; last year’s confirmation never clears this year’s flag. |
| Deadlines wrong after an FYE change | Cached due dates were not recomputed | Recompute the whole calendar from the new FYE; never store a due date independently of the FYE it derives from. |
| A registered state produces no calendar entry | State code missing from STATE_RULES |
Add its StateRule; the tracker logs and skips unknown codes rather than inventing a deadline. |
Related
- Parent section: State Charity Registration Compliance
- The full inventory of per-state obligations behind these dates: State-by-State Nonprofit Reporting Requirements Checklist
- How to express rules like these as a dispatchable table: Modeling Grantor Rules as a Decision Table in Python