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.
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, thezoneinfostdlib 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 areDecimal. Floats are forbidden anywhere a fare, discount rate, or subsidy amount is computed; a singlefloatin 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_cachein 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 canonicalrider_id, a UTCtimestamp, avalidator_id, and afare_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:
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
EntitlementProfileis not the same as a zero discount.Nonemeans “we do not yet know,” and the engine routes it toRECONCILIATION_REQUIREDat 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.timestampis only meaningful if both are timezone-aware and both are UTC. A naivedatetimefrom a mis-synced validator will raiseTypeErroron 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
reasonstring 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 theaudit_hashis stable across services with different locale settings — a hash that differs by locale breaks replay verification. - Idempotency.
generate_idempotency_keybindstap_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_discountperforms 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_idrather than a full re-scan. - Parallelism caveat. The evaluator is embarrassingly parallel per tap, but two taps for the same
rider_idare not independent — a transfer decision on the second depends on state written by the first. Shard concurrency byrider_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:
Operational Checklist
Before promoting an eligibility engine to production for a transit-ops deployment, confirm each item:
- Decimal end to end. No
floatappears anywhere in the fare, discount, or subsidy path; a linter or a runtime assertion rejectsfloatin monetary fields. - Timestamps are UTC and aware on ingress. Naive datetimes are rejected or normalized before evaluation; expiry comparisons can never straddle a timezone.
- Idempotency keys enforced at the ledger. The
tap_id+rider_id+timestampfingerprint is a unique constraint, so retries and backfills cannot double-apply a discount. - Missing entitlement routes to reconciliation, never to a silent full fare. Verified with a test that asserts
Noneentitlement yieldsRECONCILIATION_REQUIREDat base fare. - Transfer exemption supersedes program discount. A test covers a rider who is both discount-eligible and inside a transfer window, asserting a
$0.00fare. - Threshold and discount matrices are versioned and signed. Active sessions pin the version they started under; version load is atomic.
- 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.
- 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.
- Canary + rollback path exists for any new discount matrix, with parallel audit trails under old and new versions.
- 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.
Related
- Automating Senior and Student Fare Validation Rules — the demographic-eligibility how-to that plugs directly into this engine.
- Transfer Window Logic — the session state that can zero out an otherwise-discounted fare.
- Threshold Tuning Frameworks — externalized, versioned eligibility thresholds this engine reads at evaluation time.
- Fallback Calculation Chains — the degraded-mode path an unresolved predicate delegates to.
- Schema Validation Pipelines — the ingestion tier that guarantees the well-formed events this engine consumes.