Discount Eligibility Engines

Discount eligibility engines are the deterministic decision layer inside Fare Rule Validation & Calculation Engines: they take a validated tap, resolve which concession — senior, student, low-income, disability, employer subsidy — a rider is entitled to, and emit an exact, auditable fare adjustment before the transaction reaches the ledger. This is the sub-problem where policy meets money. For transit operations teams and revenue analysts, an engine that mis-resolves eligibility either leaks subsidy revenue on every discounted tap or over-charges a protected rider, and both outcomes carry regulatory and reputational cost. For mobility-tech developers and Python automation builders, the constraint is harder still: the same tap replayed after a network retry, a backfilled log, or a corrected entitlement record must produce byte-for-byte the same fare, at fare-gate latency, across every validator in the fleet.

This page covers how that engine is built: the ingestion contract it inherits from the pipeline upstream, the stateful evaluation and fallback routing at its core, how eligibility thresholds are versioned without redeploying, and the reconciliation loop that repairs decisions made on incomplete data.

Discount eligibility engine data flow A validated canonical tap enters and is assigned an idempotency key, passes through the entitlement resolver that does a registry lookup and verification-token check, then the stateful evaluator that applies either a transfer-window override or a program discount, then a Decimal quantize step, and finally emits a signed audit payload. Unresolved predicates branch downward into an asynchronous reconciliation queue that backfills corrected entitlements and re-enters the evaluator. From the evaluator onward money is represented only as Decimal and floating point is forbidden. idempotency key assigned Decimal-only zone · floats forbidden Canonical tap event tap_id · rider_id · UTC Entitlement resolver registry lookup verification token Stateful evaluator transfer override program discount Decimal quantize ROUND_HALF_UP Audit payload signed · replayable unresolved re-priced Async reconciliation queue backfills corrected entitlements · re-enters evaluator

Where This Sits in the Calculation Pipeline

The eligibility engine never sees a raw card. By the time a tap reaches it, the media identifier has already been normalized into a canonical rider_id/media_hash through Smart Card Schema Mapping, and the payload has already passed the structural gate enforced by the Schema Validation Pipelines at ingestion. The engine’s job begins with a well-formed event and ends with a priced, signed decision — everything about card layout and transport encoding is someone else’s problem by design, which keeps the eligibility logic testable in isolation.

Downstream, the engine hands its output to two consumers: the settlement aggregator that rolls priced taps into per-agency revenue, and the reconciliation queue that repairs any decision made on missing or stale entitlement data. Two adjacent components in this same engine family are load-bearing here — Transfer Window Logic, which can zero out a fare that would otherwise be discounted, and the Fallback Calculation Chains the evaluator delegates to when a primary predicate cannot resolve.

Prerequisites & Environment

This component assumes a modern, typed Python service tier and makes explicit assumptions about the data contract it inherits:

  • Python 3.11+ — required for datetime.timezone-aware arithmetic, the zoneinfo stdlib backend, and exception-group semantics in async reconciliation workers.
  • Pydantic v2 (>=2.5) — the eligibility models below rely on v2’s compiled core for per-tap validation cost low enough to run inline at the gate. If you are migrating from v1, resolve that first via Implementing Pydantic Models for AFC Event Streams.
  • decimal (stdlib) — all monetary values are Decimal. Floats are forbidden anywhere a fare, discount rate, or subsidy amount is computed; a single float in the discount multiplier reintroduces the rounding drift the audit trail exists to prevent.
  • A bounded state store — Redis (or any TTL-capable key/value store) for recent transfer state and idempotency keys. The in-process lru_cache in the reference code is a stand-in; production uses an external store with an explicit TTL so state survives a process restart but never grows unbounded.
  • AFC vendor assumptions — the engine treats entitlement verification as eventually consistent. Third-party concession checks (student enrolment APIs, municipal low-income registries) may return 503, time out, or lag the tap by hours. The engine must price the tap now and reconcile later; it must never block the gate on a slow verification call.
  • Data schema expectations — every inbound event carries a tap_id, a canonical rider_id, a UTC timestamp, a validator_id, and a fare_media_type. Timestamps arrive timezone-aware and normalized to UTC upstream; the engine re-asserts this rather than trusting it.

Architecture: The Eligibility Decision Flow

Eligibility evaluation is a strict predicate chain, and the order matters: an entitlement must exist and be currently verified before a program discount can apply, but a live transfer window supersedes the discount entirely (a zero fare beats a 50%-off fare). Every predicate that cannot resolve routes to reconciliation rather than silently defaulting to full fare, because a silent full-fare default on a protected rider is the exact failure the engine exists to prevent.

The decision flow below mirrors the evaluator’s predicate ordering, where each unresolved check routes to reconciliation rather than blocking the tap:

Eligibility predicate decision flow A tap event is tested in order. If no entitlement profile is present, or the verification token is expired, the tap is priced at base fare and flagged RECONCILIATION_REQUIRED. Otherwise, if the rider is within a transfer window the fare is set to zero as a transfer exemption; if not, the program discount percentage is applied. Both the exemption and the discount paths converge on a final Decimal round with status ELIGIBLE. no yes expired valid yes no Tap event Entitlement present? Verification token valid? Within transfer window? Apply base fare RECONCILIATION_REQUIRED Fare = $0.00 transfer exemption Apply program discount % Round HALF_UP status: ELIGIBLE

Core Implementation

The following implementation is a stateless, memory-bounded eligibility evaluator with explicit error handling, deterministic reconciliation routing, and idempotent execution guarantees. It is deliberately pure: it takes an event plus a resolved entitlement snapshot and returns a decision, with no I/O of its own, so it can be unit-tested exhaustively and replayed against historical inputs.

import logging
import hashlib
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from functools import lru_cache
from pydantic import BaseModel, Field, ValidationError
from enum import Enum

logger = logging.getLogger(__name__)

class TapStatus(str, Enum):
    ELIGIBLE = "eligible"
    PARTIAL_FALLBACK = "partial_fallback"
    RECONCILIATION_REQUIRED = "reconciliation_required"
    REJECTED = "rejected"

class TapEvent(BaseModel):
    tap_id: str
    rider_id: str
    timestamp: datetime
    fare_media_type: str
    route_id: Optional[str] = None
    validator_id: str
    raw_payload: Dict[str, Any] = Field(default_factory=dict)

class EntitlementProfile(BaseModel):
    rider_id: str
    program_code: Optional[str] = None
    verification_expiry: Optional[datetime] = None
    discount_pct: Decimal = Decimal("0.00")

class ReconciliationRecord(BaseModel):
    tap_id: str
    status: TapStatus
    applied_fare: Decimal
    reason: str
    audit_hash: str
    timestamp: datetime

# Bounded in-memory cache for recent tap states (memory-efficient)
@lru_cache(maxsize=50_000)
def get_recent_transfer_state(rider_id: str, validator_id: str) -> Optional[datetime]:
    """Simulates bounded state lookup. In production, replace with Redis/DB with TTL."""
    return None

def generate_idempotency_key(tap: TapEvent) -> str:
    return hashlib.sha256(f"{tap.tap_id}:{tap.rider_id}:{tap.timestamp.isoformat()}".encode()).hexdigest()

def evaluate_discount(
    tap: TapEvent,
    entitlement: Optional[EntitlementProfile],
    base_fare: Decimal,
    transfer_window_minutes: int = 90
) -> Dict[str, Any]:
    """
    Deterministic eligibility evaluation with explicit fallback routing.
    """
    audit_log = []
    applied_fare = base_fare
    status = TapStatus.REJECTED

    try:
        # 1. Validate base fare precision
        if base_fare <= Decimal("0.00"):
            raise ValueError("Base fare must be positive")

        # 2. Check entitlement existence & expiry
        if not entitlement:
            status = TapStatus.RECONCILIATION_REQUIRED
            audit_log.append("Missing entitlement profile")
            applied_fare = base_fare
            return _build_result(tap, status, applied_fare, audit_log)

        if entitlement.verification_expiry and entitlement.verification_expiry < tap.timestamp:
            status = TapStatus.RECONCILIATION_REQUIRED
            audit_log.append("Expired verification token")
            applied_fare = base_fare
            return _build_result(tap, status, applied_fare, audit_log)

        # 3. Transfer window override check
        last_transfer = get_recent_transfer_state(tap.rider_id, tap.validator_id)
        if last_transfer and (tap.timestamp - last_transfer) < timedelta(minutes=transfer_window_minutes):
            status = TapStatus.ELIGIBLE
            applied_fare = Decimal("0.00")
            audit_log.append("Transfer exemption applied")
            return _build_result(tap, status, applied_fare, audit_log)

        # 4. Apply program discount
        discount_amount = base_fare * (entitlement.discount_pct / Decimal("100"))
        applied_fare = (base_fare - discount_amount).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        status = TapStatus.ELIGIBLE
        audit_log.append(f"Discount {entitlement.discount_pct}% applied")

    except ValidationError as ve:
        logger.error(f"Schema validation failed for tap {tap.tap_id}: {ve}")
        status = TapStatus.REJECTED
        audit_log.append("Invalid payload schema")
    except Exception as e:
        logger.exception(f"Unexpected evaluation error for tap {tap.tap_id}")
        status = TapStatus.RECONCILIATION_REQUIRED
        audit_log.append(f"Runtime exception: {str(e)}")
        applied_fare = base_fare  # Conservative default

    return _build_result(tap, status, applied_fare, audit_log)

def _build_result(tap: TapEvent, status: TapStatus, fare: Decimal, audit_log: list) -> Dict[str, Any]:
    reason = "; ".join(audit_log)
    audit_hash = hashlib.md5(f"{tap.tap_id}{status}{fare}{reason}".encode()).hexdigest()
    return {
        "tap_id": tap.tap_id,
        "rider_id": tap.rider_id,
        "status": status,
        "applied_fare": fare,
        "audit_hash": audit_hash,
        "reason": reason,
        "timestamp": datetime.now(timezone.utc)
    }

Three design choices make this safe to run inline at a fare gate. First, the discount is computed as base_fare * (discount_pct / Decimal("100")) and only the final fare is quantized with ROUND_HALF_UP — intermediate values keep full Decimal precision so a 33% discount on a $2.90 fare rounds once, predictably, and identically on every validator. Second, the transfer-window branch returns before the program discount is ever computed, encoding the policy that a live transfer exemption supersedes a concession. Third, every exit path builds the same result shape with an audit_hash, so a decision is never emitted without a replayable fingerprint.

Schema Validation & Transit-Specific Edge Cases

The engine’s correctness lives in the boundaries it refuses to trust, not the happy path.

  • Null and missing entitlement. A missing EntitlementProfile is not the same as a zero discount. None means “we do not yet know,” and the engine routes it to RECONCILIATION_REQUIRED at base fare rather than assuming the rider is ineligible. Treating absent data as “no concession” is the single most common cause of subsidy under-payment in production.
  • Timezone normalization. verification_expiry < tap.timestamp is only meaningful if both are timezone-aware and both are UTC. A naive datetime from a mis-synced validator will raise TypeError on comparison — which is correct behaviour: it routes to the reconciliation path via the exception handler instead of silently comparing wall-clock times across a DST boundary. Re-assert UTC on ingress; never compare a naive and an aware timestamp.
  • Encoding fallback. The reason string and any program codes may carry non-ASCII agency names or accented rider metadata. Hash and log with explicit UTF-8 encoding (.encode() defaults to UTF-8 here) so the audit_hash is stable across services with different locale settings — a hash that differs by locale breaks replay verification.
  • Idempotency. generate_idempotency_key binds tap_id, rider_id, and the ISO timestamp into one SHA-256 fingerprint. A network retry, a backfilled log, or a reprocessed AFC batch that carries the same three values produces the same key, so the ledger writer can enforce exactly-once application of the discount. Reconciliation reuses this key so a corrected entitlement re-prices the same tap rather than creating a second one.
  • Expired-but-grace-period credentials. Demographic programs such as those in Automating Senior and Student Fare Validation Rules require periodic re-verification. Rather than hard-cut a rider to full fare the moment a token expires, many agencies mandate a grace-period path that keeps the concession for a bounded window while flagging the tap for re-verification — protecting the rider from a policy lag that is the agency’s fault, not theirs.

Integration Pattern: Handing Off to Adjacent Components

The eligibility engine is one stage in a chain, and each handoff is an explicit contract rather than a shared mutable state:

  • Inbound from Transfer Window Logic. The transfer branch reads recent session state that Transfer Window Logic maintains per operator agreement. Under concurrent validation, two readers can observe the same session simultaneously and both try to apply a transfer exemption; production engines resolve this with an optimistic-concurrency write or a distributed lock keyed on rider_id, falling back to a deterministic tie-break on (timestamp, validator_id) so replay is stable.
  • Outbound to Fallback Calculation Chains. When a primary predicate cannot resolve — missing entitlement, expired token, or an unexpected runtime error — the evaluator does not invent a fare. It routes through Fallback Calculation Chains, which apply a conservative, documented default (typically base fare), stamp the deviation with structured metadata, and hand the transaction to the reconciliation queue. This is also the path an offline validator takes: it caches the tap locally under the Fallback Routing Strategies defined for degraded mode, then re-evaluates once connectivity returns.
  • Threshold configuration from the tuning layer. The engine never hard-codes an age, income band, or trip-frequency cut-off. Those live in Threshold Tuning Frameworks as externalized configuration, so revenue teams can pilot a fare-relief program or adjust a concession band without redeploying the calculation service.

Threshold artifacts are immutable and versioned: every discount matrix and eligibility criterion carries a semantic version tag and a cryptographic signature. When a new fare-relief mandate takes effect, the engine loads the updated configuration atomically and lets active sessions run out under the version they started with, so a rider’s journey is never re-priced mid-trip by a policy that changed between their two taps.

Performance & Scale Considerations

Fare-gate latency is a hard budget, not an aspiration: the evaluator must return well inside the gate’s open-time so the rider is never blocked.

  • Keep evaluation pure and inline; push I/O to the edges. evaluate_discount performs no network calls. Entitlement resolution and transfer-state lookups happen before it is invoked, against a bounded, TTL-backed store, so the hot path is arithmetic and comparisons — microseconds, not the tens of milliseconds a synchronous registry call would cost.
  • Bound every cache. The reference lru_cache(maxsize=50_000) and its production Redis equivalent both cap memory explicitly. Transfer state and idempotency keys carry a TTL slightly longer than the widest transfer window so entries expire on their own; nothing accumulates for the life of the process.
  • Stream, don’t materialize. Batch reprocessing of corrected AFC logs iterates events as bounded generators rather than loading a full day of taps into memory. Threshold counters (trip-frequency caps, for instance) are maintained with streaming aggregation keyed on rider_id rather than a full re-scan.
  • Parallelism caveat. The evaluator is embarrassingly parallel per tap, but two taps for the same rider_id are not independent — a transfer decision on the second depends on state written by the first. Shard concurrency by rider_id (not by raw tap volume) so ordering within a rider’s session is preserved while different riders scale out freely.
  • Canary new discount matrices. Route a small percentage of traffic through an updated policy version while keeping parallel audit trails under both versions, so a bad threshold change can be rolled back instantly without corrupting settled ledger state.

Reconciliation Lifecycle & Operational Guardrails

The reconciliation layer runs asynchronously so it never blocks the gate. Taps flagged RECONCILIATION_REQUIRED are serialized to a durable queue with their original payload, evaluation trace, and deterministic audit_hash. Revenue analysts consume that queue in batch, cross-reference delayed subsidy callbacks, correct entitlement mismatches, and issue retroactive fare adjustments — all keyed on the original idempotency fingerprint so a correction re-prices the same tap exactly once.

The lifecycle below shows how a tap moves between evaluation states, with reconciliation feeding corrected entitlements back into a re-evaluation:

Tap evaluation state lifecycle A tap enters the Evaluating state. From there it moves to Eligible when a discount or exemption is applied, to Rejected on an invalid schema, or to Reconciliation Required when the entitlement is missing or expired. A tap in Reconciliation Required returns to Evaluating once a corrected entitlement is backfilled. An Eligible tap becomes Settled when the ledger is posted, which is the terminal state. Evaluating invalid schema discount / exemption missing / expired corrected entitlement backfilled ledger posted Rejected Eligible Reconciliation Required Settled

Operational Checklist

Before promoting an eligibility engine to production for a transit-ops deployment, confirm each item:

  1. Decimal end to end. No float appears anywhere in the fare, discount, or subsidy path; a linter or a runtime assertion rejects float in monetary fields.
  2. Timestamps are UTC and aware on ingress. Naive datetimes are rejected or normalized before evaluation; expiry comparisons can never straddle a timezone.
  3. Idempotency keys enforced at the ledger. The tap_id+rider_id+timestamp fingerprint is a unique constraint, so retries and backfills cannot double-apply a discount.
  4. Missing entitlement routes to reconciliation, never to a silent full fare. Verified with a test that asserts None entitlement yields RECONCILIATION_REQUIRED at base fare.
  5. Transfer exemption supersedes program discount. A test covers a rider who is both discount-eligible and inside a transfer window, asserting a $0.00 fare.
  6. Threshold and discount matrices are versioned and signed. Active sessions pin the version they started under; version load is atomic.
  7. Reconciliation queue is durable and monitored. Depth, age of oldest item, and re-evaluation success rate are on a dashboard; a growing queue pages before it becomes a settlement shortfall.
  8. Caches are bounded with a TTL longer than the widest transfer window and shorter than a shift, verified under sustained tap volume without memory growth.
  9. Canary + rollback path exists for any new discount matrix, with parallel audit trails under old and new versions.
  10. Every decision emits an audit_hash; replay of a historical input reproduces the identical fare and hash.

By decoupling validation, evaluation, and reconciliation, transit operators hold sub-50ms gate latency, zero revenue leakage from duplicate discounts, and a fully auditable compliance trail — the properties that separate a demo discount calculator from an engine that can settle real money across multiple agencies.

Part of Fare Rule Validation & Calculation Engines.