Calculating Multi-State Grace Periods and Cure Windows
This guide is part of the Penalty Avoidance & Grace-Period Mapping area within the Deadline Tracking & Routing Engines framework: it is the calculator that turns a due date and a jurisdiction’s rules into the two facts every downstream decision needs — the exact calendar instant the cure window closes, and which lifecycle phase the obligation occupies right now.
Engineering guide, not legal advice. The day-offsets, timezones, and citations used below are illustrative inputs for the calculator, not a statement of the law. Statutory deadlines and grace periods change and depend on the facts and the jurisdiction — confirm each value against the primary source and qualified counsel before relying on it in production.
Scope of This Page
This page covers the arithmetic of grace and cure boundaries and nothing else. It answers, for a single obligation: when does the penalty clock start, when does the reversible cure window close, and what phase is the obligation in at a given instant — all computed in the jurisdiction’s own timezone with weekend and holiday roll-forward. It deliberately excludes what to do with the answer: assembling a fee-reduction package is Automating Penalty Waiver and Abatement Requests, recovering after the window has closed is Reinstating an Administratively Dissolved Entity, and turning the resulting window into a routing priority is Priority Scoring Algorithms. Here we only compute the boundaries.
The Constraint That Forces a Timezone-Aware Clock
The reason this cannot be naive date subtraction is that the cure window closes at the end of a specific business day in a specific place, and “end of day” is a wall-clock event, not a UTC one. A California Statement of Information deadline under Cal. Corp. Code § 1502 closes at 11:59 p.m. Pacific; computed in UTC, that instant is 07:59 the following day, so a job running at 05:00 UTC that subtracts naive dates will report an obligation as expired when the filer in Sacramento still has most of a business day. The states also disagree on when the clock starts: Delaware’s penalty accrues from the statutory March 1 due date under 8 Del. C. §§ 502 and 510 with no grace, Texas starts a forfeiture clock 45 days from the date its notice is mailed under Tex. Tax Code § 171.251, and New York’s biennial status under N.Y. BCL § 408 turns on the anniversary month with no penalty clock at all. A single reference frame and a per-state definition of the clock’s origin are therefore not refinements — they are the difference between a correct boundary and a filed-too-late penalty.
Prerequisites
- Python 3.10+ — for
zoneinfo,matchon phases, andX | Yunions. - Standard library only:
datetime,zoneinfo,dataclasses,enum,logging,json. No third-party dependency is required for the arithmetic itself. - A per-state holiday set and IANA timezone id, loaded from the jurisdiction rule store this area maintains.
- A resolved, timezone-aware
dueinstant per obligation from the State Filing Deadline Calendars layer — this calculator resolves windows, never the due date itself.
Implementation: A Rules-as-Data Cure-Window Calculator
The module below reads a jurisdiction rule — clock origin, grace days, cure days, timezone, and holiday set — and returns a typed CureWindow carrying every boundary as a timezone-aware instant plus the phase at the evaluation time. Boundaries are computed in the jurisdiction’s local zone, then converted to UTC for storage, so comparison across a mixed portfolio happens in one frame. Weekend and holiday roll-forward is applied to each boundary, and the clock origin is selected per state. Comments mark the compliance-critical lines.
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import datetime, time, timedelta, timezone
from enum import Enum
from zoneinfo import ZoneInfo
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("compliance.cure_window")
class ClockOrigin(str, Enum):
DUE_DATE = "due_date" # DE, CA, NY — clock starts at the statutory due date
NOTICE_MAILED = "notice" # TX — forfeiture clock starts when the state mails notice
class Phase(str, Enum):
GRACE = "grace"
PENALTY_ACCRUING = "penalty_accruing"
EXPIRED = "expired" # cure window closed; obligation no longer reversible here
@dataclass(frozen=True)
class GraceRule:
"""Rules-as-data: one jurisdiction's cure arithmetic, editable without a deploy."""
jurisdiction: str
tz: str # IANA zone the wall-clock deadline lives in
origin: ClockOrigin
grace_days: int # days the filing is still treated as timely
cure_days: int # last day the consequence is reversible
holidays: frozenset[str] # ISO dates the filing office is closed
@dataclass(frozen=True)
class CureWindow:
"""Typed result: every boundary as a UTC instant, plus the phase at eval time."""
jurisdiction: str
grace_end_utc: datetime
penalty_start_utc: datetime
cure_end_utc: datetime
phase: Phase
seconds_to_cure_end: float # negative once expired
RULES: dict[str, GraceRule] = {
# DE: no grace; reversible until the charter is void after neglect "for 1 year" (8 Del. C. § 510).
"DE": GraceRule("DE", "America/New_York", ClockOrigin.DUE_DATE, 0, 365, frozenset()),
# CA: 60-day notice-and-cure (§ 2204); revivable up to the § 2205 suspension gated by 24 months' non-filing.
"CA": GraceRule("CA", "America/Los_Angeles", ClockOrigin.DUE_DATE, 60, 730, frozenset()),
# NY: no § 408 penalty; the ~2-year cliff is tax dissolution by proclamation (Tax Law § 203-a).
"NY": GraceRule("NY", "America/New_York", ClockOrigin.DUE_DATE, 0, 730, frozenset()),
# TX: privileges forfeit 45 days after the mailed notice (§ 171.251); that is the actionable cliff.
"TX": GraceRule("TX", "America/Chicago", ClockOrigin.NOTICE_MAILED, 45, 45, frozenset()),
}
def _end_of_business_day(day: datetime, tz: ZoneInfo, holidays: frozenset[str]) -> datetime:
"""Roll a local date forward off weekends/holidays, anchored at 23:59:59 local.
The cure window closes at end-of-day where the office sits, not at UTC midnight —
this is the line that prevents reporting an obligation dead while a business day remains.
"""
d = day.date()
while d.weekday() >= 5 or d.isoformat() in holidays: # Sat=5, Sun=6, or a listed holiday
d += timedelta(days=1)
local_close = datetime.combine(d, time(23, 59, 59), tzinfo=tz)
return local_close.astimezone(timezone.utc) # store/compare in a single frame
def compute_window(
jurisdiction: str,
due_local: datetime,
now: datetime | None = None,
notice_mailed_local: datetime | None = None,
) -> CureWindow:
"""Return the cure-window boundaries and current phase for one obligation."""
rule = RULES[jurisdiction]
tz = ZoneInfo(rule.tz)
now = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
# Select the clock origin per state: due date, or the notice-mailed date for TX.
if rule.origin is ClockOrigin.NOTICE_MAILED:
if notice_mailed_local is None:
raise ValueError(f"{jurisdiction} clock starts at notice; notice date required")
origin = notice_mailed_local.astimezone(tz)
else:
origin = due_local.astimezone(tz)
grace_end = _end_of_business_day(origin + timedelta(days=rule.grace_days), tz, rule.holidays)
cure_end = _end_of_business_day(origin + timedelta(days=rule.cure_days), tz, rule.holidays)
penalty_start = grace_end # penalty begins the instant grace lapses
if now <= grace_end:
phase = Phase.GRACE
elif now <= cure_end:
phase = Phase.PENALTY_ACCRUING
else:
phase = Phase.EXPIRED
window = CureWindow(
jurisdiction=jurisdiction,
grace_end_utc=grace_end,
penalty_start_utc=penalty_start,
cure_end_utc=cure_end,
phase=phase,
seconds_to_cure_end=(cure_end - now).total_seconds(),
)
logger.info(json.dumps({
"event": "cure_window_computed",
"jurisdiction": jurisdiction,
"phase": phase.value,
"grace_end_utc": grace_end.isoformat(),
"cure_end_utc": cure_end.isoformat(),
"hours_to_cure_end": round(window.seconds_to_cure_end / 3600.0, 2),
}))
return window
if __name__ == "__main__":
# A California SOI due at local end-of-day; evaluated 90 days later.
due = datetime(2026, 4, 1, tzinfo=ZoneInfo("America/Los_Angeles"))
result = compute_window("CA", due, now=datetime(2026, 6, 30, tzinfo=timezone.utc))
print(result.phase, result.cure_end_utc.isoformat())
The calculator is a pure function of a rule and two instants: no I/O, no hidden clock. That purity is deliberate — a CureWindow computed during an audit from a persisted due and now must reproduce exactly, so a regulator replaying the inputs sees the same boundaries the engine acted on.
Configuration Reference
Every field is data the calculator reads, never a branch, because each is dictated by statute or by the registry’s operating calendar rather than by code.
| Parameter | Example | Legal / operational justification |
|---|---|---|
tz |
America/Los_Angeles |
The wall-clock deadline lives in the registry’s zone; end-of-day is local, not UTC. |
origin |
NOTICE_MAILED (TX) |
Privileges forfeit 45 days after the Comptroller’s mailed notice under Tex. Tax Code § 171.251, not the report due date. |
grace_days |
60 (CA) | Notice-and-cure interval before the $250 penalty is assessed under Corp. Code § 2204. |
cure_days |
365 (DE) | Delaware voids a charter only after tax neglect “for 1 year” (8 Del. C. § 510), so the delinquency stays reversible for the full year. |
holidays |
per-state ISO set | Boundaries landing on a closed day roll forward; a missing holiday is a one-day error. |
now |
injected instant | Passed explicitly so an audit replay is deterministic; never read from a hidden global clock. |
Failure Modes and Fallback Routing
Each fault maps onto the parent discipline’s error categorization & retry logic taxonomy, and the calculator handles each distinctly.
- A naive
duedatetime reaches the calculator (data-validation). Subtracting a naive local time from a UTCnowsilently shifts the boundary by the offset — up to eight hours for Pacific entities. The calculator converts every input throughastimezone, and inputs that arrive withouttzinfomust be rejected upstream, not coerced, because a coerced zone is a wrong zone. - Texas obligation with no notice-mailed date (data-validation). The forfeiture clock cannot start from the report due date, so
compute_windowraises rather than guessing. The obligation routes to a hold-for-data queue until the notice date is captured, because an assumed origin produces a confidently wrong cure-end. - A boundary lands on a state holiday the calendar is missing (data-validation). The window closes one business day too early, and the engine reports
EXPIREDwhile the office would still accept the filing. Reconcile the per-state holiday set against the registry’s published closures; this is the single most common one-day discrepancy in the whole area. - Portfolio spanning DST transitions (system). A boundary computed on a spring-forward or fall-back day shifts by an hour in UTC. Because all comparison happens after conversion to UTC and the local close is anchored at 23:59:59 in the zone-aware object,
zoneinforesolves the offset correctly; the failure mode is only reintroduced if a caller re-derives boundaries from naive local dates downstream.
Frequently Asked Questions
Why compute boundaries in local time and store them in UTC instead of just working in UTC throughout?
Because the deadline is a wall-clock event where the registry sits — end of business day in that state — and “end of day” is only well-defined in local time. A California cure window closes at 23:59:59 Pacific, which is 07:59:59 UTC the next day. Computing the boundary locally and then converting to UTC captures the correct instant; starting in UTC forces you to guess the offset and gets it wrong across DST. Storing the result in UTC then lets a mixed-jurisdiction portfolio be compared in one frame.
Why does Texas need a separate notice-mailed date when the others use the due date?
Texas ties its forfeiture clock to the date the Comptroller mails the forfeiture notice — privileges forfeit 45 days later under Tex. Tax Code § 171.251 — not to the report’s original due date. Using the due date would start the clock weeks early and report a shorter cure window than the entity actually has. The calculator models the origin as data — ClockOrigin.NOTICE_MAILED — and refuses to compute a Texas window without the notice date rather than silently substituting the due date.
How do I keep an audit replay deterministic when "now" keeps moving?
Inject now as an explicit argument rather than reading a hidden global clock, exactly as the module does. During an audit, replay the persisted due, notice_mailed, and now instants through the same rule and the calculator reproduces the identical boundaries and phase. Any divergence then indicates a changed rule record, which is itself a version-controlled, reviewable event — not an opaque clock difference.