Deadline Tracking Routing Engines

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, match on phases, and X | Y unions.
  • 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 due instant 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.

Computing grace-end, penalty-start and cure-end boundaries on a per-state local timeline, then reading the phase off the position of now A local due instant feeds a clock-origin selector, three boundaries are placed on a local-time axis, each is rolled forward off weekends and holidays and converted to UTC, and the phase is the segment containing the evaluation instant. One obligation: pick the clock origin, place the boundaries, roll off non-business days, read the phase Clock-origin selector statutory due date | notice-mailed date local time (jurisdiction zone) → origin day 0 grace-end origin + grace_days = penalty-start cure-end origin + cure_days now GRACE PENALTY-ACCRUING EXPIRED each boundary: roll forward off Sat/Sun/state holiday → then convert local → UTC for storage a boundary on a closed day lands on the next business day; comparison happens in one frame
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.

  1. A naive due datetime reaches the calculator (data-validation). Subtracting a naive local time from a UTC now silently shifts the boundary by the offset — up to eight hours for Pacific entities. The calculator converts every input through astimezone, and inputs that arrive without tzinfo must be rejected upstream, not coerced, because a coerced zone is a wrong zone.
  2. Texas obligation with no notice-mailed date (data-validation). The forfeiture clock cannot start from the report due date, so compute_window raises 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.
  3. 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 EXPIRED while 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.
  4. 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, zoneinfo resolves 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.