Encoding Reg E Dispute Timelines in Python
The deadline that blows a Regulation E investigation is almost never the one an engineer expected — it is the Friday notice whose ten "days" quietly rolled through two weekends and Presidents' Day, landing the real due date four calendar days later than the naive arithmetic said. This reference gives you a single annotated function that computes every §1005.11 deadline from the notice date on a proper US banking-day calendar. It sits under Regulation E error resolution for ACH disputes, which specifies the obligations, inside the broader Reg E, NACHA and audit controls practice; here the concern is purely the calendar arithmetic, because that is where correct obligations become incorrect dates.
The clocks are of two incompatible kinds and the code must never blur them. The investigation windows (ten business days standard, twenty for new-account/POS/foreign) and the results notice (three business days) are business-day counts that skip weekends and the ten Federal Reserve holidays. The extension caps (forty-five and ninety days) are calendar-day counts measured straight from the notice date. Computing business days forward from a date is where is the holidays scanned, effectively constant for the small Reg E uses — the cost is correctness, not speed.
The Full Deadline Calculator
The function below takes the notice date and the transaction classification and returns a dataclass of every downstream deadline. It builds the Federal Reserve holiday set for the relevant years, walks business days one at a time skipping weekends and holidays, and keeps the calendar-day extension caps as plain timedelta addition. Amounts are not its concern; dates are.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from enum import Enum
from functools import lru_cache
class TxnClass(Enum):
"""Drives whether the base window is 10 or 20 business days and whether
the extension cap is 45 or 90 calendar days (12 CFR 1005.11(c)(2)-(3))."""
STANDARD = "standard"
NEW_ACCOUNT = "new_account"
POINT_OF_SALE = "pos"
FOREIGN_INITIATED = "foreign"
@dataclass(frozen=True, slots=True)
class RegEDeadlines:
notice_date: date
investigation_due: date # 10 or 20 business days
provisional_credit_due: date # coincides with investigation_due
extended_resolution_due: date # 45 or 90 calendar days
results_notice_due: date # 3 business days after conclusion
def _nth_weekday(year: int, month: int, weekday: int, n: int) -> date:
"""The nth given weekday of a month (weekday: Mon=0 .. Sun=6)."""
d = date(year, month, 1)
offset = (weekday - d.weekday()) % 7
return d + timedelta(days=offset + 7 * (n - 1))
def _last_weekday(year: int, month: int, weekday: int) -> date:
"""The last given weekday of a month (used for Memorial Day)."""
d = date(year, month, 28)
while d.month == month:
nxt = d + timedelta(days=1)
if nxt.month != month:
break
d = nxt
while d.weekday() != weekday:
d -= timedelta(days=1)
return d
@lru_cache(maxsize=8)
def federal_reserve_holidays(year: int) -> frozenset[date]:
"""The ten Federal Reserve Bank holidays for a year. When a fixed-date
holiday falls on a Sunday the Fed observes the following Monday; a
Saturday holiday is NOT shifted for Fed purposes (banks are already closed)."""
fixed = {
date(year, 1, 1), # New Year's Day
date(year, 6, 19), # Juneteenth
date(year, 7, 4), # Independence Day
date(year, 11, 11), # Veterans Day
date(year, 12, 25), # Christmas Day
}
observed: set[date] = set()
for h in fixed:
observed.add(h + timedelta(days=1) if h.weekday() == 6 else h)
floating = {
_nth_weekday(year, 1, 0, 3), # MLK Day — 3rd Mon Jan
_nth_weekday(year, 2, 0, 3), # Presidents' Day — 3rd Mon Feb
_last_weekday(year, 5, 0), # Memorial Day — last Mon May
_nth_weekday(year, 9, 0, 1), # Labor Day — 1st Mon Sep
_nth_weekday(year, 10, 0, 2), # Columbus Day — 2nd Mon Oct
_nth_weekday(year, 11, 3, 4), # Thanksgiving — 4th Thu Nov
}
return frozenset(observed | floating)
def is_banking_day(d: date) -> bool:
"""A Federal Reserve banking day: a weekday that is not a Fed holiday."""
if d.weekday() >= 5: # Saturday or Sunday
return False
return d not in federal_reserve_holidays(d.year)
def add_business_days(start: date, n: int) -> date:
"""Advance `n` banking days from `start`, skipping weekends and Fed
holidays. The start date itself is never counted; day 1 is the next
banking day. This is business-day arithmetic ONLY — do not use it for the
45/90-day calendar caps."""
if n < 0:
raise ValueError("Reg E deadlines only move forward")
current = start
remaining = n
while remaining > 0:
current += timedelta(days=1)
if is_banking_day(current):
remaining -= 1
return current
def compute_reg_e_deadlines(notice_date: date, txn_class: TxnClass) -> RegEDeadlines:
"""Compute every Reg E deadline from the notice-of-error date.
Business-day windows (investigation, results notice) skip weekends and
Federal Reserve holidays. Calendar-day windows (the 45/90-day extension
caps) are measured straight from the notice date, holidays included.
"""
special = txn_class in (
TxnClass.NEW_ACCOUNT, TxnClass.POINT_OF_SALE, TxnClass.FOREIGN_INITIATED,
)
base_business_days = 20 if special else 10
extension_calendar_days = 90 if special else 45
investigation_due = add_business_days(notice_date, base_business_days)
return RegEDeadlines(
notice_date=notice_date,
investigation_due=investigation_due,
provisional_credit_due=investigation_due,
extended_resolution_due=notice_date + timedelta(days=extension_calendar_days),
results_notice_due=add_business_days(investigation_due, 3),
)
Calibration for the Special Transaction Classes
The single input that changes the whole result set is txn_class, and it must be derived from data, not from an analyst's guess:
- New account. If the account was opened within thirty days before the disputed transaction, pass
TxnClass.NEW_ACCOUNT. Derive it by comparing the account-open date to the transaction date at intake — the base investigation becomes twenty business days and the extension cap ninety calendar days. - Point of sale. Debit-card POS transactions carry the same twenty-business-day / ninety-calendar-day figures. Classify from the transaction's SEC/entry metadata, not from the dispute channel.
- Foreign-initiated. Transfers initiated outside the United States also use the extended clocks. A domestic ACH with a foreign originator name is not automatically foreign-initiated; classify on where the transfer was initiated.
Everything not in those three classes is TxnClass.STANDARD: ten business days and forty-five calendar days.
Before and After: Concrete Dates
Take a notice received Friday 2026-02-13, the week before Presidents' Day (Monday 2026-02-16 is a Federal Reserve holiday). The naive and correct results diverge immediately.
| Deadline | Naive notice + timedelta(days=n) |
compute_reg_e_deadlines (banking days) |
|---|---|---|
| Investigation due (standard, 10) | 2026-02-23 (Mon) | 2026-03-02 (Mon) — skipped 4 weekend days + Presidents' Day |
| Provisional credit due | 2026-02-23 | 2026-03-02 |
| Results notice (3 business days after) | 2026-02-26 | 2026-03-05 (Thu) |
| Extension cap (calendar, 45) | 2026-03-30 | 2026-03-30 — calendar count, identical |
The naive computation would have you close the investigation and issue any provisional credit a full week early on paper — and, worse, report a due date to your compliance dashboard that is seven calendar days off. For a new-account dispute the same notice yields an investigation due of 2026-03-13 (twenty business days) and an extension cap of 2026-05-14 (ninety calendar days). Feeding the function a date for the notice and the correct TxnClass is the entire contract; the holiday set does the rest.
Failure Modes & Guardrails
- Holiday calendar drift. The floating Federal Reserve holidays move every year, and Juneteenth only became a federal holiday in 2021. Hardcoding a static list, or copying last year's dates, silently miscounts. The
federal_reserve_holidaysfunction recomputes floating dates per year and islru_cached so repeated calls stay cheap; refresh the observance rules whenever the Fed changes its schedule. - Business-day vs calendar-day mixups. The most damaging bug is running the 45/90-day cap through
add_business_days(making it far too long) or the 10-day investigation throughtimedelta(making it too short). Keep the two arithmetics in separate code paths — the function above never crosses them — and unit-test a case that spans a holiday so a regression surfaces immediately. - Timezone and "date of receipt" ambiguity. A notice that arrives at 11:58 p.m. in the consumer's timezone may be the next day in the bank's. Normalize the notice to the institution's local banking date before passing it in; the calculator operates on
date, so the timezone decision must be made and logged upstream, not hidden inside the arithmetic.
Frequently Asked Questions
Why not just use numpy.busday_offset or a third-party holiday library?
You can, provided the holiday set is the Federal Reserve's — which differs from the generic US federal set in its Sunday-observance rule and does not shift Saturday holidays. The self-contained function here makes the Fed-specific rules explicit and auditable, which matters when an examiner asks how a specific due date was derived. If you use a library, pin its holiday calendar to the Fed schedule and test the boundary cases above.
Does the start date count as day one?
No. add_business_days never counts the notice date itself; the first business day is the next banking day after the notice. This matches how §1005.11 windows are measured — the clock runs from receipt, and receipt day zero is not one of the counted business days.
How do I handle a Saturday holiday like July 4th falling on a weekend?
For Federal Reserve purposes a holiday that falls on a Saturday is not shifted — the banks are already closed that day, so no separate observance is added. A holiday on a Sunday is observed the following Monday. The federal_reserve_holidays function encodes exactly this: it shifts only Sunday-dated fixed holidays forward and leaves Saturday ones alone.
What if the notice arrives outside the 60-day statement bar?
That is a separate check under §1005.11(b) and this function does not enforce it — it computes deadlines assuming the notice is timely. Gate the timeliness bar (notice within sixty calendar days of the statement) before calling the calculator, and log a late notice rather than starting the mandatory clocks, because a late notice does not trigger the §1005.11(c) obligations.
Related guides in this collection
- Regulation E Error Resolution for ACH Disputes — the parent reference specifying every §1005.11 obligation this calculator dates.
- Automating Provisional-Credit Decisions Under Reg E — the sibling that consumes these deadlines to decide when credit is mandatory.
- Reg E, NACHA & Audit Controls for Payment Reconciliation — the grandparent envelope tying the dispute clocks to audit and return handling.
- SLA Timers and Exception Aging for Reconciliation Breaks — the general aging-timer machinery a Reg E clock is a regulated instance of.