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.

Revenue-protection loop from tap stream to ledger and tuning A tap stream flows into real-time anomaly scoring, then into batch evasion analytics, then into chargeback and dispute reconciliation, and finally into the revenue ledger where recovered and at-risk amounts land. Real-time scoring and batch analytics both emit signals down into a threshold tuning store, which feeds tuned thresholds back up into the real-time scorer, closing the loop. $ at-risk risk scores evasion signals tuned thresholds Tap stream gate + on-board Real-time scoring velocity · sequences Batch analytics evasion · cohorts Chargeback recon EMV disputes Revenue ledger Threshold tuning store weights · cut-points · shadow config

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 dd and elapsed time Δt\Delta t, the implied speed is v=d/Δtv = d / \Delta t; when vv 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:

R=min ⁣(100,  iwisi),si[0,1]R = \min\!\left(100,\; \sum_{i} w_i \cdot s_i \right), \qquad s_i \in [0, 1]

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.

Streaming and batch topology with human review Online lane: the tap stream feeds the real-time RiskScorer, which emits a disposition. Allow and flag dispositions write straight to the revenue ledger; review dispositions drop into a review queue that human analysts adjudicate before writing back to the ledger. Offline lane: the ledger feeds batch evasion analytics nightly, which emits at-risk features into the threshold tuning store, which pushes tuned thresholds back up into the RiskScorer. allow / flag + $at-risk review adjudicated nightly at-risk features tuned thresholds Tap stream Kafka topic RiskScorer real-time Disposition allow / flag / review Revenue ledger recovered / at-risk Review queue bounded backlog Analysts human adjudication Batch evasion analytics Threshold tuning weights · cut-points

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 [0,100][0, 100] and a Decimal fare-at-risk, and maps the score to a disposition through externally supplied cut-points.

Per-tap risk scoring and disposition flow A tap event feeds signal extraction across velocity, geographic impossibility, sequence, and blacklist detectors. Their weighted contributions aggregate into a bounded risk score. If the score is at or above the review cut-point the tap is routed to human review with its fare-at-risk booked. Otherwise, if it is at or above the flag cut-point it is flagged but allowed with the fare-at-risk logged. Otherwise it is allowed as clean. yes no yes no Tap event Signal extraction velocity geo seq blacklist Aggregate risk score clamp 0..100 Score ≥ review cut? Score ≥ flag cut? Review book fare-at-risk Flag allow + log risk Allow charge normal fare

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.

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.