Fraud Detection & Revenue Protection
Fraud detection and revenue protection is the discipline of finding the taps that should not have been priced the way they were — fare evasion, card sharing, cloned media, and contactless chargeback abuse — inside a live tap stream, without punishing the overwhelming majority of riders who did nothing wrong. Every uncaught pattern is revenue that leaks out of the system one journey at a time; every over-eager block is a paying customer stranded at a gate and a support ticket the agency never wanted. The Fare Rule Validation & Calculation Engines upstream decide what a tap should cost, and the amounts this subsystem recovers or writes off eventually land in Revenue Reconciliation & Settlement; revenue protection is the layer in between that decides which of those priced taps deserve a second look.
The risk is asymmetric and easy to get wrong in both directions. Under-detect and the agency bleeds fare revenue, absorbs chargeback losses on disputed EMV transactions, and eventually faces an audit finding on uncollected fares. Over-detect and legitimate riders get flagged, families tapping the same card get treated as evaders, and a false-positive rate that reads fine in aggregate becomes a reputational problem the moment a local reporter finds the rider who was wrongly accused. Ownership is shared across three roles: revenue-protection analysts who tune the thresholds and adjudicate flags, transit operations who guarantee that detection never traps a rider, and the Python developers who build the scoring and analytics so that every automated flag is explainable and every dollar of fare-at-risk is a Decimal, never a guess.
Overview
The subsystem is a loop, not a pipeline. Taps arrive as a continuous stream, get a real-time risk score, roll up into batch evasion analytics overnight, and any card-linked disputes get reconciled against contactless chargebacks. The signals those stages produce feed two places: the revenue ledger, where recovered and at-risk amounts are recorded, and the threshold store, where tuning decisions close the loop back onto the real-time scorer.
Nothing in this loop blocks a rider on its own. The real-time scorer emits a disposition, not a barrier; the batch layer explains and quantifies rather than enforces; and the human-review queue is where any accusatory action originates. That separation is deliberate, and the rest of this page shows how to build it so it recovers real revenue while keeping the false-positive cost visible and bounded.
Domain taxonomy
The vocabulary here is narrow but load-bearing, because a “signal” to an analyst and a “signal” to a settlement bank are different objects, and conflating them is how a detection program loses the trust of the operations team. A revenue-protection system reasons over a small set of entities: the raw material is a scored tap, the currency is a bounded risk score plus a Decimal fare-at-risk, and the output is a disposition that routes the tap to one of three fates.
The signal types the scorer evaluates are the concrete detectors, each answering one narrow question about a tap in the context of the media’s recent history:
- Velocity — how many taps has this media produced in the last few minutes, and does the count exceed what a single physical rider could generate? The canonical card-sharing tell.
- Geographic impossibility — does the implied speed between two consecutive taps exceed any plausible travel speed? Given distance and elapsed time , the implied speed is ; when exceeds a modal ceiling the sequence is physically impossible for one rider.
- Sequence violation — does the tap order break the finite-state grammar of valid journeys (a tap-out with no tap-in, two entries with no exit, re-entry inside an anti-passback window)?
- Blacklist hit — is the media on a hotlist of reported-stolen, charged-back, or known-cloned identifiers?
Each detector contributes a weighted term to a single bounded risk score. The score is deliberately capped so no one signal can dominate and so the number stays interpretable across tuning cycles:
The entities that carry these values through the system:
| Entity | Role in revenue protection | Key fields |
|---|---|---|
TapContext |
One tap plus the recent per-media history needed to score it | media_hash, tap_timestamp_utc, zone_id, location, prior_taps |
Signal |
A single fired detector with its weight and reason | name, weight, strength, explanation |
RiskAssessment |
The scored result for one tap | media_hash, risk_score, fare_at_risk, disposition, fired_signals |
Disposition |
The routing decision | one of allow, flag, review |
Hotlist |
Versioned set of blocked or reported media identifiers | list_id, entries, effective_from, source |
EvasionCohort |
A batch-derived group sharing an evasion pattern | cohort_id, pattern, estimated_leakage, sample_taps |
The fare_at_risk field is the money view of a suspicious tap: the Decimal amount the agency stands to lose if the pattern is real and uncollected. It is never a float, because these amounts are summed across millions of taps and rolled into the same ledger that Revenue Reconciliation & Settlement balances to the cent — a fraction-of-a-cent drift per tap becomes a reconciliation break at scale. The three dispositions form a strict escalation: allow charges the normal fare and moves on, flag still charges and lets the rider through but records the fare-at-risk and the reason for later analysis, and review escalates to a human before any rider-facing consequence. The media identity the whole system keys on is the canonical media_hash produced upstream by smart-card schema mapping — the raw card serial never reaches the scorer.
System architecture
The topology is two lanes that share a scoring vocabulary. The online lane runs synchronously with the tap: it must return a disposition in single-digit milliseconds so it never becomes the reason a gate is slow. The offline lane runs on the accumulated history: batch evasion analytics and the human-review queue operate on hours or days of taps, produce the estimates and adjudications that a millisecond budget can never afford, and write their conclusions back into the same ledger and threshold store the online lane reads.
Online lane: scoring in the tap budget
The online path partitions the tap stream by media_hash so every tap for a given card lands on the same worker with the same recent history, then scores it against a small window of prior taps held in a bounded local cache. Keeping the scorer stateless with respect to global data — it reads thresholds and hotlists from a versioned store, never mutates them — is what makes it horizontally scalable and what lets a candidate ruleset run in tap pattern anomaly detection shadow mode alongside production. The disposition it returns is advisory to the fare engine: a flag never changes the charged fare, it only annotates the ledger entry.
Offline lane: analytics and human review
Batch evasion analytics reads the accumulated ledger and tap history to estimate leakage that no single tap reveals — gate-tap gap analysis, cohort clustering, and route-level evasion rates — and is the domain of fare evasion analytics. The human-review queue is the only place an accusatory outcome is produced; analysts adjudicate review-disposition taps, and their decisions become labeled training signal for the next tuning cycle. Card-linked payment disputes take a third path into chargeback & dispute automation, which reconciles EMV contactless chargebacks against the taps that generated them so a genuine dispute is refunded and a friendly-fraud dispute is contested with evidence.
Core implementation pattern
The heart of the online lane is a rule-based RiskScorer that turns one TapContext into one RiskAssessment. It is deliberately rule-based rather than an opaque model: every point of the risk score traces to a named signal with a human-readable explanation, so an analyst reviewing a flag sees which detectors fired and why, and a rider disputing one can be given a real reason. The scorer produces a bounded score in and a Decimal fare-at-risk, and maps the score to a disposition through externally supplied cut-points.
The reference scorer below is stateless per call, takes the tap and its recent per-media history, and returns exactly one assessment. Each detector is a small pure function that returns a Signal (or None if it did not fire); the scorer sums their weighted strengths, clamps the total, and records the fired signals so the decision is fully explainable. Money is Decimal throughout, logging is structured, and every failure path is a typed exception — there is no bare except that could swallow a scoring bug and silently let a real evader through.
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from typing import Callable, Literal, Optional, Sequence
logger = logging.getLogger("fraud.risk_scorer")
Disposition = Literal["allow", "flag", "review"]
MONEY = Decimal("0.01")
class RiskScoringError(Exception):
"""Raised when a tap cannot be scored deterministically."""
@dataclass(frozen=True)
class PriorTap:
tap_timestamp_utc: datetime
zone_id: str
lat: float
lon: float
@dataclass(frozen=True)
class TapContext:
media_hash: str
tap_timestamp_utc: datetime
zone_id: str
lat: float
lon: float
base_fare: Decimal # fare the engine would charge for this tap
prior_taps: Sequence[PriorTap] # recent, chronological, same media
hotlisted: bool = False
@dataclass(frozen=True)
class Signal:
name: str
weight: Decimal # points contributed at full strength
strength: Decimal # in [0, 1]
explanation: str
@property
def points(self) -> Decimal:
return self.weight * self.strength
@dataclass(frozen=True)
class RiskAssessment:
media_hash: str
risk_score: Decimal # bounded 0..100
fare_at_risk: Decimal # Decimal, minor-unit precise
disposition: Disposition
fired_signals: tuple[Signal, ...]
scored_at: datetime
def _haversine_km(a_lat: float, a_lon: float, b_lat: float, b_lon: float) -> float:
from math import asin, cos, radians, sin, sqrt
r = 6371.0
dlat = radians(b_lat - a_lat)
dlon = radians(b_lon - a_lon)
h = sin(dlat / 2) ** 2 + cos(radians(a_lat)) * cos(radians(b_lat)) * sin(dlon / 2) ** 2
return 2 * r * asin(sqrt(h))
@dataclass(frozen=True)
class ScorerConfig:
"""Externally supplied, versioned. Never hard-coded in the hot path."""
velocity_window: timedelta = timedelta(minutes=3)
velocity_max_taps: int = 3
velocity_weight: Decimal = Decimal("40")
impossible_speed_kmh: float = 250.0
geo_weight: Decimal = Decimal("35")
sequence_weight: Decimal = Decimal("25")
blacklist_weight: Decimal = Decimal("100")
flag_cut: Decimal = Decimal("45")
review_cut: Decimal = Decimal("80")
def _velocity_signal(ctx: TapContext, cfg: ScorerConfig) -> Optional[Signal]:
horizon = ctx.tap_timestamp_utc - cfg.velocity_window
recent = [p for p in ctx.prior_taps if p.tap_timestamp_utc >= horizon]
if len(recent) < cfg.velocity_max_taps:
return None
excess = len(recent) - cfg.velocity_max_taps + 1
strength = min(Decimal(1), Decimal(excess) / Decimal(cfg.velocity_max_taps))
return Signal(
name="velocity",
weight=cfg.velocity_weight,
strength=strength,
explanation=f"{len(recent)} taps within {cfg.velocity_window}",
)
def _geo_signal(ctx: TapContext, cfg: ScorerConfig) -> Optional[Signal]:
if not ctx.prior_taps:
return None
last = ctx.prior_taps[-1]
dt_hours = (ctx.tap_timestamp_utc - last.tap_timestamp_utc).total_seconds() / 3600.0
if dt_hours <= 0:
return None # non-monotonic; handled by the sequence detector
km = _haversine_km(last.lat, last.lon, ctx.lat, ctx.lon)
implied_kmh = km / dt_hours
if implied_kmh <= cfg.impossible_speed_kmh:
return None
over = Decimal(str(implied_kmh)) / Decimal(str(cfg.impossible_speed_kmh))
strength = min(Decimal(1), (over - Decimal(1)))
return Signal(
name="geo_impossibility",
weight=cfg.geo_weight,
strength=strength,
explanation=f"implied {implied_kmh:.0f} km/h between consecutive taps",
)
def _sequence_signal(ctx: TapContext, cfg: ScorerConfig) -> Optional[Signal]:
if not ctx.prior_taps:
return None
last = ctx.prior_taps[-1]
if ctx.tap_timestamp_utc <= last.tap_timestamp_utc:
return Signal(
name="sequence_violation",
weight=cfg.sequence_weight,
strength=Decimal(1),
explanation="non-monotonic tap order for media",
)
return None
def _blacklist_signal(ctx: TapContext, cfg: ScorerConfig) -> Optional[Signal]:
if not ctx.hotlisted:
return None
return Signal(
name="blacklist",
weight=cfg.blacklist_weight,
strength=Decimal(1),
explanation="media on active hotlist",
)
DETECTORS: tuple[Callable[[TapContext, ScorerConfig], Optional[Signal]], ...] = (
_velocity_signal,
_geo_signal,
_sequence_signal,
_blacklist_signal,
)
class RiskScorer:
"""Rule-based, explainable, stateless per-call tap scorer."""
def __init__(self, config: ScorerConfig) -> None:
self.cfg = config
def score(self, ctx: TapContext) -> RiskAssessment:
if ctx.tap_timestamp_utc.tzinfo is None:
raise RiskScoringError(f"naive timestamp for media {ctx.media_hash}")
if not isinstance(ctx.base_fare, Decimal):
raise RiskScoringError("base_fare must be Decimal, not float")
fired: list[Signal] = []
for detect in DETECTORS:
try:
signal = detect(ctx, self.cfg)
except (ValueError, ArithmeticError) as exc:
logger.error(
"fraud.detector.failed",
extra={"detector": detect.__name__, "media": ctx.media_hash, "error": str(exc)},
)
raise RiskScoringError(str(exc)) from exc
if signal is not None:
fired.append(signal)
raw = sum((s.points for s in fired), Decimal(0))
score = min(Decimal(100), raw).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
disposition = self._disposition(score)
# Fare-at-risk is booked only when the tap is not cleanly allowed.
fare_at_risk = (
Decimal("0.00")
if disposition == "allow"
else ctx.base_fare.quantize(MONEY, rounding=ROUND_HALF_UP)
)
logger.info(
"fraud.scored",
extra={
"media": ctx.media_hash,
"score": str(score),
"disposition": disposition,
"signals": [s.name for s in fired],
},
)
return RiskAssessment(
media_hash=ctx.media_hash,
risk_score=score,
fare_at_risk=fare_at_risk,
disposition=disposition,
fired_signals=tuple(fired),
scored_at=datetime.now(ctx.tap_timestamp_utc.tzinfo),
)
def _disposition(self, score: Decimal) -> Disposition:
if score >= self.cfg.review_cut:
return "review"
if score >= self.cfg.flag_cut:
return "flag"
return "allow"
Because the config is external and versioned, an analyst can retune flag_cut, review_cut, or a detector weight against historical taps without a code deploy, and the fired_signals tuple on every assessment is what makes an audit answerable: a flagged tap comes with the exact detectors and strengths that produced its score, not a bare number nobody can defend.
Security & compliance boundaries
A revenue-protection system holds two dangerous things at once — payment-adjacent identifiers and accusations about individual riders — so its compliance posture is stricter than the rest of the fare stack, not looser.
PII minimization on media identifiers. The scorer keys on the salted media_hash, never a raw PAN, card serial, or account token. Hotlists store hashes, not card numbers, so a leaked blacklist reveals no recoverable payment data. Full PAN handling, where a card system still needs it for a chargeback, stays behind the tokenization perimeter described in AFC System Security Boundaries; revenue protection consumes tokens and hashes only.
Retention limits. Raw tap trails are the fuel for velocity and geographic detection, but they are also a movement-tracking dataset. Retain the granular per-media history only as long as the detection and dispute windows require — typically the chargeback dispute horizon plus a settlement buffer — then aggregate to cohort level and drop the tap-level detail. The scorer’s bounded prior_taps window enforces this at read time; a retention job enforces it at rest.
Auditability of automated flags. Every flag and review disposition is an append-only record carrying the score, the fired signals, and the config version that produced it. A disposition is never edited in place; a reversal is a new compensating record. This is what lets an operator answer, months later, exactly why a given rider’s tap was flagged and on which ruleset — the same replay guarantee the calculation engine gives for pricing.
Avoiding disparate impact. Evasion analytics must not become a proxy for demographics. Detectors key on physics and sequence — implied speed, tap counts, order — not on route, neighborhood, or fare-product class in ways that would concentrate flags on protected groups. Before any threshold change ships, batch analytics reports the flag rate by route and time-of-day so a tuning decision that would disproportionately flag one corridor is caught in review, following the auditability and least-privilege principles in the security-boundaries guide. Automated detection informs human judgment; it never issues a citation on its own.
Operational resilience
Revenue protection sits in the tap path, so its failure modes must never become the rider’s problem.
Degrade to allow. If the scorer is unreachable, slow, or throwing, the fare engine charges the normal fare and lets the rider through — detection fails open, never closed. A dropped scoring call costs the agency at most the fare-at-risk on a handful of taps; a scoring call that blocks a gate costs the agency a stranded rider and a headline. The online lane therefore runs on a strict timeout with an allow-on-timeout default, and taps that missed real-time scoring are swept up by the batch lane later.
Idempotent scoring. Scoring one tap twice must yield the same assessment and book the fare-at-risk only once. Assessments are keyed on the same composite identity the ledger uses — media hash, timestamp, validator — so redelivery from the stream broker is a no-op on the ledger. The scorer is a pure function of its inputs and config version, which makes re-scoring during replay reproducible to the point.
Shadow-mode rollout. No new ruleset or threshold change goes straight to enforcing. A candidate config runs in shadow: it scores the live tap stream in parallel, writes its dispositions to a shadow ledger, and its flag and review rates are diffed against production before promotion. This is how a weight change that would triple the review queue gets caught before it drowns the analysts, and it is the discipline that keeps false-positive cost measurable rather than discovered in production.
Bounded human review. The review queue has a finite backlog budget. When adjudication capacity is saturated, excess review-disposition taps degrade to flag — recorded and analyzable, but not blocking — rather than piling into an unbounded queue that guarantees stale, useless flags.
Edge cases & pitfalls
Most false positives in revenue protection trace to a short list of recurring edges, each with a deterministic remedy.
Clock drift causing false velocity flags. Validator NTP skew compresses the apparent gap between two taps and can fabricate an impossible velocity. The scorer reasons in UTC and applies a bounded skew tolerance before the velocity and geographic detectors fire; a tap whose timestamp is implausible for its device is quarantined for repair against sync logs, not scored as an evader. This is the same clock discipline the Transfer Window Logic depends on, and a drift problem there surfaces as a false-positive problem here.
Legitimate back-to-back taps. A parent tapping the same card for two children in ten seconds looks exactly like card sharing to a naive velocity detector. The remedy is to model permitted multi-rider products explicitly: a card provisioned for group travel raises the velocity threshold rather than tripping it, and the detector’s strength scales with how far the count exceeds the product-specific allowance, not a global constant.
Transfers versus re-entry. A valid transfer and an evasive re-entry can produce a similar tap pair. The disambiguator is the transfer window: a second tap inside the operator’s transfer window is a priced continuation the engine already blessed, so revenue protection must not double-count it as anomalous. The scorer consumes the transfer decision rather than re-deriving it, which keeps a single legitimate journey from being both discounted and flagged.
Delayed chargebacks. A contactless chargeback can land days or weeks after the tap it disputes, long after the tap left the real-time window. These are reconciled in the batch lane by joining the dispute back to the original tap on the tokenized reference, then routed through dispute automation to refund a genuine dispute or contest a friendly-fraud one with the tap evidence. The at-risk amount is only written off once the dispute resolves, so an open dispute never prematurely reduces recognized revenue in Revenue Reconciliation & Settlement.
Anti-passback double taps. A rider re-presenting media at a slow gate produces a duplicate that a sequence detector could misread. Suppress it on the composite idempotency key exactly as the fare engine does, before the sequence detector runs, so a hardware retry never scores as an evasion pattern.
Explore this section
The child components that together implement revenue protection, each covering one part of the loop:
- Tap Pattern Anomaly Detection — the real-time detectors, from tap-velocity card-sharing checks to flagging physically impossible journey sequences in the live stream.
- Fare Evasion Analytics — the batch lane that estimates leakage and evasion rates from gate-tap gaps and clusters riders into evasion cohorts.
- Chargeback & Dispute Automation — reconciling EMV contactless chargebacks against the taps that generated them, refunding genuine disputes and contesting friendly fraud.
Related
- Transfer Window Logic — the transfer decision the scorer consumes so a legitimate continuation is never double-counted as anomalous.
- Revenue Reconciliation & Settlement — where recovered fares and resolved at-risk amounts are balanced into the ledger.
- Fare Rule Validation & Calculation Engines — the upstream engine whose priced taps are the input to every risk assessment.
- AFC System Security Boundaries — the tokenization perimeter and least-privilege model that keeps PII out of the scorer and hotlists.
Part of the transit-fare.org fare automation reference, alongside sibling sections Core Architecture & Fare Taxonomy, Fare Data Ingestion & GTFS-RT Sync, and Revenue Reconciliation & Settlement.