Tracking Timely-Filing Appeal Deadlines
Problem: an appeal filed one day past the payer’s window is denied on procedure no matter how strong its merits, and the clock starts at the 835 remittance date — not the day a biller opened the denial. This guide shows the exact Python to compute a per-payer appeal deadline from the remittance production date, handle business days and payer-specific overrides, and surface at-risk claims before they expire. It implements the deadline component of Automated Appeal Letter Generation.
Prerequisites
Spec Reference: Payer Appeal Windows
The deadline rule per payer is a small table of window length and counting convention. Encode it as configuration, never inline in the calculator — windows change, and a Medicaid plan’s rule differs from Medicare’s.
| Payer (example) | First-level window | Counting basis | Anchor date | Notes |
|---|---|---|---|---|
| Medicare (redetermination) | 120 days | Calendar | 835 remittance date | Filed with the MAC; second level (reconsideration) is 180 days |
| Commercial payer A | 180 days | Calendar | 835 remittance date | Typical large-commercial first-level window |
| Commercial payer B | 90 days | Business | 835 remittance date | Shorter window; business-day counting |
| Medicaid plan C | 60 days | Calendar | 835 remittance date | State plans vary widely; always config-driven |
The anchor is the remittance production date because that is the date the payer treats as the determination date. Windows are stated in calendar days unless the payer contract specifies business days — a distinction that shifts a deadline by several days across weekends and holidays.
Step-by-Step Implementation
Step 1 — Model the payer window and compute the deadline
Model the window as a typed rule keyed on payer and appeal level, then add the window to the anchor date. Calendar-day windows are a plain timedelta; business-day windows step day by day, skipping weekends and holidays.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from enum import Enum
logger = logging.getLogger("appeal.deadline")
class Basis(Enum):
CALENDAR = "calendar"
BUSINESS = "business"
@dataclass(frozen=True)
class AppealWindow:
payer_id: str
appeal_level: str # "redetermination", "reconsideration", ...
window_days: int # e.g. 120 for Medicare redetermination
basis: Basis = Basis.CALENDAR
def add_business_days(start: date, days: int, holidays: frozenset[date]) -> date:
"""Advance `days` business days from `start`, skipping weekends/holidays."""
current = start
remaining = days
while remaining > 0:
current += timedelta(days=1)
if current.weekday() < 5 and current not in holidays: # Mon–Fri, non-holiday
remaining -= 1
return current
def compute_deadline(
remittance_date: date,
window: AppealWindow,
holidays: frozenset[date] = frozenset(),
) -> date:
"""Compute the appeal filing deadline from the 835 remittance date."""
if window.basis is Basis.CALENDAR:
return remittance_date + timedelta(days=window.window_days)
return add_business_days(remittance_date, window.window_days, holidays)
Step 2 — Resolve the window per payer and build a claim record
Look up the window from configuration by payer and level, then attach the computed deadline to a lightweight tracking record keyed on the payer claim control number (CLP07).
# Config-driven; in production this comes from the payer rule store.
WINDOWS: dict[tuple[str, str], AppealWindow] = {
("medicare", "redetermination"): AppealWindow("medicare", "redetermination", 120),
("commercial_a", "reconsideration"): AppealWindow("commercial_a", "reconsideration", 180),
("commercial_b", "reconsideration"): AppealWindow(
"commercial_b", "reconsideration", 90, Basis.BUSINESS
),
}
@dataclass(frozen=True)
class TrackedAppeal:
clp07: str # payer claim control number
payer_id: str
appeal_level: str
remittance_date: date # 835 production date
deadline: date
def track(clp07: str, payer_id: str, level: str, remittance_date: date,
holidays: frozenset[date] = frozenset()) -> TrackedAppeal:
window = WINDOWS[(payer_id, level)]
deadline = compute_deadline(remittance_date, window, holidays)
logger.info(
"Deadline computed | clp07=%s | payer=%s | level=%s | deadline=%s",
clp07, payer_id, level, deadline.isoformat(),
)
return TrackedAppeal(clp07, payer_id, level, remittance_date, deadline)
Step 3 — Build the at-risk alert queue
Sweep the tracked appeals nightly and surface any whose deadline falls inside an alert threshold, sorted soonest-first so the highest-urgency claims are worked before the render queue fills.
def at_risk(appeals: list[TrackedAppeal], today: date, threshold_days: int = 14
) -> list[TrackedAppeal]:
"""Return open appeals expiring within `threshold_days`, soonest first."""
cutoff = today + timedelta(days=threshold_days)
risky = [a for a in appeals if today <= a.deadline <= cutoff]
risky.sort(key=lambda a: a.deadline)
for a in risky:
days_left = (a.deadline - today).days
level = logging.WARNING if days_left > 3 else logging.ERROR
logger.log(
level, "Appeal at risk | clp07=%s | payer=%s | days_left=%d",
a.clp07, a.payer_id, days_left,
)
return risky
Verification
Confirm the calendar and business-day paths compute the expected deadlines and that the alert queue orders by urgency. Use de-identified placeholder control numbers only.
# Medicare 120 calendar days from a remittance dated 2026-05-04.
w = WINDOWS[("medicare", "redetermination")]
assert compute_deadline(date(2026, 5, 4), w) == date(2026, 9, 1)
# Business-day window skips the weekend.
bw = AppealWindow("commercial_b", "reconsideration", 5, Basis.BUSINESS)
assert compute_deadline(date(2026, 5, 1), bw) == date(2026, 5, 8) # Fri -> next Fri
appeals = [track("CTRL0000000", "medicare", "redetermination", date(2026, 5, 4))]
risky = at_risk(appeals, today=date(2026, 8, 25), threshold_days=14)
assert risky and risky[0].clp07 == "CTRL0000000"
Expected log output (control numbers only, no PHI):
2026-08-25 07:00:00 | INFO | appeal.deadline | Deadline computed | clp07=CTRL0000000 | payer=medicare | level=redetermination | deadline=2026-09-01
2026-08-25 07:00:00 | WARNING | appeal.deadline | Appeal at risk | clp07=CTRL0000000 | payer=medicare | days_left=7
Common Gotchas
- Anchoring on the receipt date instead of the remittance date. The clock starts on the 835 production date the payer used, not the day your system ingested the file or a biller opened the denial. Anchoring on receipt silently shortens — or, worse, lengthens past the true deadline — every window. Persist the
BPR16/GS04remittance date with the claim. - Ignoring timezones and holidays. A deadline computed in one timezone and evaluated in another can slip a day at the boundary; a business-day window that skips weekends but not federal holidays overcounts available days. Normalize to the payer’s timezone with
zoneinfoand feed a holiday calendar intoadd_business_days. - Hard-coding a single window. Medicare’s 120 days is not Medicaid’s 60 or a commercial payer’s 90/180. Keep windows in the payer-specific rule boundary configuration and version them by effective date so a rule change never retroactively mis-dates an open appeal.
- No safety margin. A deadline equal to today is already too late once mailing and payer receipt time are counted. Alert on a threshold (e.g. 14 days) and treat under-3-days as an error-level escalation, not a warning.
Related
- Parent guide: Automated Appeal Letter Generation — the appeal record whose
filing_deadlinethis component computes. - Generating Appeal Letters with Jinja2 — prints the deadline computed here onto the payer-specific letter.
- Payer-Specific Rule Boundary Configuration — the versioned source of each payer’s appeal window and counting basis.