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.

python
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

  1. 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_holidays function recomputes floating dates per year and is lru_cached so repeated calls stay cheap; refresh the observance rules whenever the Fed changes its schedule.
  2. 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 through timedelta (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.
  3. 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.