Encoding Provisional-Credit State Transitions in Python

Of every edge in the exception lifecycle, one moves real money before the investigation is finished: the transition into PROVISIONAL_CREDIT_ISSUED. This page sits inside the exception lifecycle state machine cluster — itself part of the broader exception routing and fraud-pattern detection reference — and takes one surgical concern: how to encode that transition as a guarded function that refuses to fire unless Regulation E eligibility holds, records the credit amount as Decimal cents, and stamps a reversal deadline onto the immutable transition record. Get the guard wrong and you either withhold credit a consumer is legally owed, or you post credit twice against a single dispute.

Provisional credit is not a courtesy. Under Regulation E (12 CFR 1005.11), when a financial institution cannot resolve a consumer's alleged electronic-fund-transfer error within ten business days of receiving notice, it generally must provisionally credit the disputed amount — including interest where the account earns it — while it completes an investigation of up to forty-five days (ninety for new accounts, point-of-sale, or foreign-initiated transactions). The transition guard is where that legal test becomes a boolean, and the reversal deadline it stamps is what the SLA aging and breach machinery later watches.

Concept Spec: The Guard Is a Conjunction

The transition into PROVISIONAL_CREDIT_ISSUED is legal only when every clause of a conjunction holds. Let the item carry an account type, an error classification, a notice date , and the current business-day count elapsed since . The guard permits the move when

The first two clauses are eligibility: commercial accounts fall under UCC Article 4A, not Reg E, and a dispute that is not a covered error (a customer simply regretting a payment) triggers no credit obligation. The third clause is the timing trigger — provisional credit is required only once the ten-business-day initial window is exceeded without resolution. The fourth is the idempotency guard that prevents a double credit, and it is the one most often missing in practice. Evaluating the guard is ; the only non-trivial cost is computing , the business-day delta, which is a bounded calendar walk covered in the aging guide.

The reversal deadline stamped on the transition is business days, where is 45 or 90 depending on the error class — the outer bound by which the institution must either finalize or reverse the credit. Storing that deadline on the transition record rather than a side table means the obligation is inseparable from the event that created it.

Full Annotated Implementation

The function below is the guard-and-apply for the provisional-credit edge. It refuses to fire on ineligible items, holds the credit amount as Decimal cents (never float), computes the reversal deadline on a business-day calendar, and returns an immutable transition record ready to append to the lifecycle log.

python
from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime, timezone, timedelta
from decimal import Decimal
from enum import Enum
from typing import Optional


class ErrorClass(str, Enum):
    STANDARD = "STANDARD"        # 45 business-day outer window
    NEW_ACCOUNT = "NEW_ACCOUNT"  # 90: account open < 30 days
    POS = "POS"                  # 90: point-of-sale debit
    FOREIGN = "FOREIGN"          # 90: foreign-initiated transfer


OUTER_WINDOW: dict[ErrorClass, int] = {
    ErrorClass.STANDARD: 45,
    ErrorClass.NEW_ACCOUNT: 90,
    ErrorClass.POS: 90,
    ErrorClass.FOREIGN: 90,
}

INITIAL_WINDOW_DAYS = 10   # Reg E 12 CFR 1005.11: resolve or provisionally credit


@dataclass(frozen=True, slots=True)
class ProvisionalCredit:
    """Immutable payload attached to the PROVISIONAL_CREDIT_ISSUED transition."""
    amount_cents: int          # disputed amount in integer cents, never float
    interest_cents: int        # Reg E requires interest where the account earns it
    issued_on: date
    reversal_deadline: date    # outer bound to finalize or reverse
    error_class: ErrorClass


class NotEligibleForCredit(Exception):
    """Raised when the Reg E provisional-credit guard rejects the transition."""


def business_days_between(start: date, end: date, holidays: frozenset[date]) -> int:
    """Count banking days in (start, end], excluding weekends and Fed holidays."""
    if end <= start:
        return 0
    count, cursor = 0, start
    while cursor < end:
        cursor += timedelta(days=1)
        if cursor.weekday() < 5 and cursor not in holidays:
            count += 1
    return count


def add_business_days(start: date, n: int, holidays: frozenset[date]) -> date:
    """Return the date n banking days after start."""
    cursor, added = start, 0
    while added < n:
        cursor += timedelta(days=1)
        if cursor.weekday() < 5 and cursor not in holidays:
            added += 1
    return cursor


def issue_provisional_credit(
    *,
    is_consumer: bool,
    is_reg_e_error: bool,
    already_credited: bool,
    notice_date: date,
    today: date,
    disputed_amount: Decimal,
    interest_amount: Decimal,
    error_class: ErrorClass,
    holidays: frozenset[date],
) -> ProvisionalCredit:
    """Guarded builder for the INVESTIGATING -> PROVISIONAL_CREDIT_ISSUED edge.

    Fires only when the Reg E conjunction holds: a consumer account, a covered
    error, the 10-business-day initial window exceeded, and no prior credit.
    Raises NotEligibleForCredit otherwise. Money is Decimal cents throughout.
    """
    if not is_consumer:
        raise NotEligibleForCredit("commercial account -> UCC 4A, not Reg E")
    if not is_reg_e_error:
        raise NotEligibleForCredit("dispute is not a covered Reg E error")
    if already_credited:
        raise NotEligibleForCredit("provisional credit already issued (idempotency)")

    elapsed = business_days_between(notice_date, today, holidays)
    if elapsed <= INITIAL_WINDOW_DAYS:
        raise NotEligibleForCredit(
            f"initial window not exceeded: {elapsed} <= {INITIAL_WINDOW_DAYS}"
        )

    cents = int((disputed_amount * 100).to_integral_value())
    interest_cents = int((interest_amount * 100).to_integral_value())
    if cents <= 0:
        raise NotEligibleForCredit("disputed amount must be positive")

    deadline = add_business_days(
        notice_date, OUTER_WINDOW[error_class], holidays
    )
    return ProvisionalCredit(
        amount_cents=cents,
        interest_cents=interest_cents,
        issued_on=today,
        reversal_deadline=deadline,
        error_class=error_class,
    )

Calibration: Consumer vs Commercial, and Error Type

The guard's branches are calibration points, not constants to hardcode past:

  • Account holder. Only consumer accounts fall under Reg E. Commercial and business accounts are governed by UCC Article 4A and the account agreement, which carry no mandatory provisional-credit clock. The is_consumer flag must come from an authoritative account-type source, not be inferred from transaction size — a large consumer dispute is still a consumer dispute.
  • Error classification. The error_class selects the outer window. A standard debit-card dispute runs 45 business days; a dispute on an account open fewer than 30 days, a point-of-sale debit, or a foreign-initiated transfer runs 90. Misclassifying a new-account dispute as standard stamps a deadline 45 business days too early and can trigger a premature reversal.
  • Interest. Reg E requires that provisional credit include interest where the account would have earned it. Compute interest_amount from the account's accrual terms over the disputed period; passing Decimal("0.00") is correct only for non-interest-bearing accounts, and treating it as always-zero is a common under-credit defect.

Before / After Walkthrough

Consider dispute EX-20260702-4471 on a consumer checking account. The consumer's written notice is received on 2026-07-02; the disputed unauthorized ACH debit is $1,240.00; the account is interest-bearing and accrues $0.86 over the window; the error is STANDARD. Fed holidays in the window include Independence Day observed 2026-07-03.

Before (naive, buggy). An implementation that counts calendar days and stores money as float would compute "10 days elapsed" by 2026-07-12 and post float(1240.0) — but calendar-day counting ignores the weekend and the 07-03 holiday, so it credits several banking days early, and the float amount risks a sub-cent drift when interest is added.

After (guarded, correct). On 2026-07-17, business_days_between(2026-07-02, 2026-07-17) excludes both weekends and the 07-03 holiday, yielding 10 business days elapsed — so the guard on that date still rejects (10 is not greater than 10). On 2026-07-20, elapsed is 11, the conjunction holds, and the function returns:

json
{
  "amount_cents": 124000,
  "interest_cents": 86,
  "issued_on": "2026-07-20",
  "reversal_deadline": "2026-09-04",
  "error_class": "STANDARD"
}

The reversal_deadline of 2026-09-04 is 45 business days after the 07-02 notice date, computed on the same banking-day calendar. That record is appended — immutably — to the lifecycle log as the payload of the INVESTIGATING → PROVISIONAL_CREDIT_ISSUED transition, and the aging machinery now watches 2026-09-04 as the hard bound.

Failure Modes & Guardrails

  1. Double credit. Without the already_credited clause, a retried job or a second analyst can fire the transition twice and post duplicate credit against one dispute. The guard rejects a repeat, and because the lifecycle state is a projection of an append-only log, the machine also refuses a second INVESTIGATING → PROVISIONAL_CREDIT_ISSUED edge — two independent defenses against the same error.
  2. Missing reversal timer. If the deadline is computed but not persisted onto the transition, the obligation is invisible to the aging layer and the institution can blow past the 45- or 90-day bound. Stamp reversal_deadline on the immutable ProvisionalCredit payload itself, never in a mutable side field that can drift.
  3. Float money. float(1240.00) + float(0.86) can land a sub-cent off, and at volume those cents accumulate into real reconciliation noise on the credit ledger. Hold the disputed and interest amounts as Decimal, convert to integer cents once with to_integral_value(), and keep the credit in cents from there forward.

Frequently Asked Questions

When is provisional credit actually mandatory under Reg E?

When a consumer alleges a covered electronic-fund-transfer error and the institution cannot complete its investigation within ten business days of receiving the consumer's notice. At that point provisional credit for the disputed amount, plus any interest earned, is generally required while the institution takes up to 45 business days to finish (90 for new accounts, point-of-sale, or foreign-initiated transfers). The guard encodes exactly that conjunction so credit is neither withheld when owed nor issued when it is not.

Why does the guard reject at exactly ten business days?

Because the obligation triggers only once the initial ten-business-day window is exceeded without resolution — the institution has the full ten days to resolve the error before any credit is due. The guard uses elapsed <= INITIAL_WINDOW_DAYS to reject, so day ten still permits an ordinary resolution and only day eleven onward compels provisional credit. Computing that count on a banking-day calendar, not calendar days, is what keeps the trigger legally correct.

How is the reversal deadline different from the credit date?

The credit date is when funds post to the account; the reversal deadline is the outer bound — 45 or 90 business days from the notice date — by which the institution must either finalize the credit or, if it determines no error occurred, reverse it with proper consumer notice. Storing the deadline on the transition record ties the reversal obligation to the credit event, so the aging layer can flag an approaching bound before it breaches.

What stops the same dispute being credited twice?

Two layers. The already_credited clause in the guard rejects a repeat call, and the finite-state machine itself only allows one INVESTIGATING → PROVISIONAL_CREDIT_ISSUED edge because the current state is projected from an append-only log — once the item is in PROVISIONAL_CREDIT_ISSUED, that edge is no longer legal. Defense in depth matters here because a duplicate credit is both a financial loss and an audit finding.