Skip to content

Configuring Promotion Eligibility Windows

When a promotion is contracted for the calendar month but a distributor invoice posts at 2026-03-01T00:12:00+01:00, the line item lands just outside the window, the accrual is never recognized, and the vendor claim is later rejected as ineligible. Settlement accuracy is fundamentally a function of deterministic temporal boundaries, and configuring promotion eligibility windows is the control that decides which transactions qualify for accruals, claims, and final payouts. This page documents the exact procedure for declaring an authoritative date anchor, normalizing to UTC, and evaluating a transaction against an inclusive, drift-tolerant window. It is the implementation-level companion to the eligibility rule framework cluster, which governs how every predicate — temporal, volume, channel — is evaluated before financial settlement.

A half-open promotion eligibility window with a symmetric tolerance buffer, resolving three transactions to reason codes A single timeline. The teal band is the contracted window from window_start (inclusive) to window_end (exclusive). Gold bands on either side are the tolerance buffer: start minus tolerance and end plus tolerance. A transaction before the buffered start resolves to WINDOW_PREMATURE, one inside the teal band resolves to WITHIN_WINDOW, and a late-arriving invoice that lands after window_end but inside the trailing buffer resolves to WITHIN_TOLERANCE. contracted window window_start [ inclusive window_end ) exclusive start − tolerance end + tolerance txn arrives early WINDOW_PREMATURE qualified = False txn inside window WITHIN_WINDOW qualified = True late invoice WITHIN_TOLERANCE qualified = True

Prerequisites

Before configuring any window logic, confirm the following are in place:

  • A single authoritative anchor per agreement. Each promotion must declare exactly one of invoice_date, shipment_date, delivery_date, or POS scan_date as its qualifying anchor. A window that silently evaluates against whichever date is present invites dual-counting; mandate one anchor in agreement schema design and store it as a first-class field.
  • Window metadata modeled as an entity, not an embedded date pair — window_start, window_end, anchor_field, tolerance_hours, and a window_version hash, stored alongside tier thresholds, product hierarchies, channel restrictions, and funding-source identifiers.
  • Normalized transaction records delivered from the upstream data ingestion normalization pipelines, carrying a raw anchor timestamp and a source_timezone tag.
  • Python packages: pydantic>=2.6 and the standard-library zoneinfo (Python 3.9+) and decimal modules. On 3.8, install backports.zoneinfo. All monetary fields use decimal.Decimal; never float.
  • Access role: read access to the contract registry and write access to the exception queue (typically the reconciliation_etl service role).

Step-by-step implementation

Step 1 — Model the window with an explicit anchor and tolerance

Encode the window as a Pydantic v2 model so that a missing anchor or a reversed interval is a load-time failure rather than a silent default.

python
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from pydantic import BaseModel, field_validator, model_validator

class AnchorField(StrEnum):
    INVOICE_DATE = "invoice_date"
    SHIPMENT_DATE = "shipment_date"
    DELIVERY_DATE = "delivery_date"
    SCAN_DATE = "scan_date"

class EligibilityWindow(BaseModel):
    promo_id: str
    anchor_field: AnchorField        # exactly one authoritative anchor
    window_start: datetime           # must be tz-aware
    window_end: datetime             # must be tz-aware
    tolerance_hours: int = 0
    rebate_rate: Decimal             # money math stays in Decimal

    @field_validator("window_start", "window_end")
    @classmethod
    def tz_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("window boundaries must be timezone-aware")
        return v

    @model_validator(mode="after")
    def ordered(self) -> "EligibilityWindow":
        if self.window_end <= self.window_start:
            raise ValueError("window_end must follow window_start")
        return self

Validation check: instantiate the model against a known-good contract row and assert window.anchor_field is a member of AnchorField — a free-text anchor means an ungoverned date field will be evaluated.

Step 2 — Normalize every boundary and anchor to UTC

Timezone drift is the dominant source of off-by-one window failures when promotions span cross-border distribution centers. Convert all boundaries and the transaction anchor to UTC at ingestion and evaluate exclusively in UTC; apply localized display offsets only at the reporting layer. Use ISO 8601 parsing rather than ad-hoc string splitting.

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

def to_utc(value: str) -> datetime:
    """Parse an ISO-8601 timestamp and return a UTC-aware datetime.

    Naive inputs (no offset, no 'Z') are treated as UTC so the engine
    never silently assumes the host machine's local timezone.
    """
    dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=ZoneInfo("UTC"))
    return dt.astimezone(timezone.utc)

Validation check: assert to_utc(raw).utcoffset().total_seconds() == 0 for every parsed value before comparison. A non-zero offset means the timestamp was never normalized and the interval test will drift.

Step 3 — Evaluate against a half-open interval with a symmetric buffer

Use a half-open interval [start, end) to eliminate duplicate qualification during midnight rollovers, and expand both boundaries by the contracted tolerance to absorb carrier transit and distributor receiving lag. Return a boolean flag plus a machine-readable reason code — reason codes are what let vendor managers adjudicate disputes and finance teams trace accrual reversals.

python
from datetime import timedelta

def evaluate_window(txn_ts: str, window: EligibilityWindow) -> dict:
    """Return qualification status and a machine-readable reason code."""
    txn = to_utc(txn_ts)
    start = window.window_start.astimezone(timezone.utc)
    end = window.window_end.astimezone(timezone.utc)
    buffer = timedelta(hours=window.tolerance_hours)

    if start <= txn < end:
        return {"qualified": True, "reason": "WITHIN_WINDOW"}
    if (start - buffer) <= txn < (end + buffer):
        return {"qualified": True, "reason": "WITHIN_TOLERANCE"}
    if txn < (start - buffer):
        return {"qualified": False, "reason": "WINDOW_PREMATURE"}
    return {"qualified": False, "reason": "WINDOW_EXPIRED"}

Validation check: assert that a transaction exactly equal to window_end resolves to WINDOW_EXPIRED (or WITHIN_TOLERANCE when a buffer is set), never WITHIN_WINDOW — this proves the upper bound is exclusive.

Step 4 — Backfill late-arriving transactions on a scheduled cadence

Late-arriving transactions are inevitable in retail supply chains. Re-evaluate unqualified rows against active windows on a scheduled job rather than dropping them at first pass, so a valid claim that simply arrived after close is still recognized.

python
def backfill(unqualified: list[dict], windows: dict[str, EligibilityWindow]) -> dict:
    recovered, still_failing = [], []
    for row in unqualified:
        window = windows.get(row["promo_id"])
        if window is None:
            still_failing.append({**row, "reason": "NO_ACTIVE_WINDOW"})
            continue
        result = evaluate_window(row[window.anchor_field], window)
        (recovered if result["qualified"] else still_failing).append({**row, **result})
    return {"recovered": recovered, "still_failing": still_failing}

Validation check: len(recovered) + len(still_failing) must equal the input count — a shortfall means rows were dropped instead of reclassified.

Step 5 — Route residual exceptions to manual review

Rows that fall outside the buffered window must not be silently discarded. Route them, with their reason code, to the exception queue that downstream fallback routing logic adjudicates, defaulting to the most conservative boundary when system state is ambiguous.

python
def to_exception(row: dict) -> dict:
    return {
        "promo_id": row["promo_id"],
        "txn_id": row["txn_id"],
        "anchor_used": row.get("anchor_field"),
        "reason_code": row["reason"],
        "window_version": row.get("window_version"),
    }

Validation check: every exception row carries a non-null reason_code drawn from the known set — a null or free-text code breaks root-cause reporting and dispute SLAs.

Common failure modes and fixes

  1. Naive timestamps assumed to be local. A feed that drops its offset arrives naive; treating it as the host machine’s local time injects a fixed hours offset of drift. Localize explicitly or treat as UTC at the parse boundary, as in to_utc.
  2. Inclusive end boundary. A closed interval [start, end] double-counts the midnight transaction shared by adjacent windows. Use half-open [start, end) so each instant belongs to exactly one period.
  3. Tolerance baked into the boundary instead of the schema. Widening window_end in the contract to absorb carrier lag silently changes the qualifying period for every consumer. Model tolerance_hours as a separate field so the raw window stays auditable.
  4. Dual anchors evaluated. Falling back from invoice_date to shipment_date when one is missing dual-counts the same economic event. Enforce one anchor_field per agreement and quarantine rows missing that anchor.
  5. Float in the rebate rate. Reading rebate_rate as float compounds rounding across millions of line items and pushes accruals into the wrong band. Parse as Decimal at ingestion and keep it there through settlement.
  6. Agreement drift. Terms amended mid-cycle without propagating the window update leave the engine evaluating a stale boundary. Compare the live promotion schedule against the configured window each run and alert when the window_version hash diverges.

Operational checklist

Frequently asked questions

Why a half-open interval instead of an inclusive BETWEEN? Adjacent promotion windows share their boundary instant. With a closed interval, the midnight transaction qualifies for both periods and is accrued twice. Half-open [start, end) assigns each instant to exactly one window, which is what makes month-end close deterministic.

Where should the tolerance buffer live? In the agreement schema as tolerance_hours, never widened into the raw window_end. Keeping the contracted window and the operational buffer separate means an auditor can see the negotiated period and the lag allowance independently, and a WITHIN_TOLERANCE reason code makes every buffered qualification traceable.

What happens when a promotion is amended mid-cycle? The window_version hash changes. Drift detection compares the live schedule against the configured window each run and raises an alert on divergence, so over- or under-accruals from a stale boundary are caught before they reach payout structure modeling rather than after settlement.

How does this differ from claim-side date checks? This page configures the window the accrual engine evaluates at ingestion. The downstream claim validation rule engine re-checks the same boundaries against submitted vendor claims; the two must share the same anchor and interval semantics or claims will reconcile against a different period than the accrual.