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.
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 POSscan_dateas 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 awindow_versionhash, 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_timezonetag. - Python packages:
pydantic>=2.6and the standard-libraryzoneinfo(Python 3.9+) anddecimalmodules. On 3.8, installbackports.zoneinfo. All monetary fields usedecimal.Decimal; neverfloat. - Access role: read access to the contract registry and write access to the exception queue (typically the
reconciliation_etlservice 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.
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.
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.
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.
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.
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
- 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. - 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. - Tolerance baked into the boundary instead of the schema. Widening
window_endin the contract to absorb carrier lag silently changes the qualifying period for every consumer. Modeltolerance_hoursas a separate field so the raw window stays auditable. - Dual anchors evaluated. Falling back from
invoice_datetoshipment_datewhen one is missing dual-counts the same economic event. Enforce oneanchor_fieldper agreement and quarantine rows missing that anchor. - Float in the rebate rate. Reading
rebate_rateasfloatcompounds rounding across millions of line items and pushes accruals into the wrong band. Parse asDecimalat ingestion and keep it there through settlement. - 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_versionhash 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.
Related
- Parent cluster: Eligibility Rule Framework
- Agreement Schema Design — where the anchor field and window entity are defined
- Fallback Routing Logic — adjudicating exceptions that fall outside the buffered window
- Date Window Alignment Checks — the claim-side counterpart that re-validates these boundaries against submitted claims