Applying Daily Fare Caps Across Multi-Operator Journeys

A daily fare cap promises a rider that once their accumulated spend reaches a ceiling, every further tap that day is free — but on a network where a bus, a metro, and a regional rail operator each price and report taps independently, the accumulator that enforces that promise has to run across all of them from one running total. This page implements the capping accumulator: it tracks a rider’s Decimal spend through the day, charges only up to the cap, and handles the single hardest tap — the one that straddles the ceiling, where the rider must be charged the remainder to the cap rather than the full fare. It is a leaf of the Threshold Tuning Frameworks component inside Fare Rule Validation & Calculation Engines.

The capping charge formula

For a tap with nominal fare ff when the rider has already spent spent\text{spent} against a daily cap CC, the amount actually charged is the fare or the remaining headroom, whichever is smaller, floored at zero:

charge=max ⁣(0,  min(f,  Cspent))\text{charge} = \max\!\bigl(0,\; \min(f,\; C - \text{spent})\bigr)

Below the cap, CspentfC - \text{spent} \geq f so the rider pays the full ff. On the straddling tap, Cspent<fC - \text{spent} < f so the rider pays only the headroom and the running total lands exactly on CC. Once spent=C\text{spent} = C, every remaining tap charges 00. The same shape extends to a weekly cap CwC_w: charge against the tighter of the two headrooms, min(f,Cspentday,Cwspentweek)\min(f, C - \text{spent}_{\text{day}}, C_w - \text{spent}_{\text{week}}).

Capping decision flow

The accumulator resolves each tap against the current running total. The first branch catches the already-capped rider so a settled cap short-circuits before any arithmetic; the second distinguishes the ordinary below-cap tap from the straddling tap that must be clipped to the remaining headroom.

Daily-cap accumulator decision for a single tap A tap with nominal fare f arrives while the rider has already spent an amount against the daily cap C. If spent already equals the cap the tap is charged zero as a capped free ride. Otherwise the remaining headroom C minus spent is compared to f: if the headroom is at least f the full fare f is charged and added to the running total; if the headroom is less than f only the headroom is charged, the running total lands exactly on the cap, and the tap is marked as the straddling tap. Every path updates the running total and emits a charged amount in Decimal. no spent = C headroom ≥ f headroom < f Tap fare f, running spent Already at cap C? Headroom C−spent vs f charge 0.00 capped free ride charge f below cap, full fare charge C−spent straddling tap clipped Update running total (Decimal)

Step 1 — A Decimal running total keyed by rider and service day

The accumulator’s state is one running total per rider per service day, and the service day is not always the calendar day — many networks roll the cap at 03:00 or 04:00 local so a late-night journey counts against the day it started. Resolve every tap to a service-day key before touching the total, and hold the total as a Decimal so the cap comparison is exact to the cent. The store here is an in-process dict for clarity; production swaps it for a TTL-backed key/value store the same way the parent framework does.

import logging
from dataclasses import dataclass, field
from datetime import datetime, time, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo

logger = logging.getLogger("afc.fare_cap")


@dataclass(frozen=True)
class CapPolicy:
    """Daily and optional weekly caps for one fare product, in Decimal."""
    daily_cap: Decimal
    weekly_cap: Decimal | None = None
    service_day_start: time = time(3, 0)   # cap rolls at 03:00 local
    tz: str = "America/New_York"

    def service_day(self, tap_utc: datetime) -> str:
        """Map a UTC tap to its local service-day key (YYYY-MM-DD)."""
        if tap_utc.tzinfo is None:
            raise ValueError("tap timestamp must be timezone-aware UTC")
        local = tap_utc.astimezone(ZoneInfo(self.tz))
        if local.time() < self.service_day_start:
            local = local - timedelta(days=1)
        return local.date().isoformat()


@dataclass
class RiderSpend:
    """Mutable per-rider accumulator across the service day and week."""
    day_key: str
    day_spent: Decimal = Decimal("0.00")
    week_spent: Decimal = Decimal("0.00")
    tap_count: int = 0

Anchoring the total on the service day rather than the wall-clock date is what keeps a 01:30 tap on the same running total as the 23:00 tap that preceded it. Everything downstream — the cap comparison, the straddle clip — depends on both taps hashing to the same day_key.

Step 2 — The capping accumulator

The apply_tap method is the whole task in one place: compute the headroom against both the daily and weekly caps, charge the smaller of the fare and that headroom, and advance the running total by exactly what was charged. Because the charge is min(fare, headroom), the straddling tap falls out of the same expression as the ordinary tap — there is no special-case branch, which is precisely why it is hard to get wrong.

from decimal import ROUND_HALF_UP
from enum import Enum


class CapOutcome(str, Enum):
    BELOW_CAP = "below_cap"
    STRADDLE = "straddle"      # this tap reached the cap
    CAPPED = "capped"          # already at cap; free ride


@dataclass(frozen=True)
class CapCharge:
    charged: Decimal
    outcome: CapOutcome
    day_spent_after: Decimal
    operator_id: str


class DailyCapAccumulator:
    def __init__(self, policy: CapPolicy) -> None:
        self.policy = policy
        self._state: dict[str, RiderSpend] = {}

    def apply_tap(
        self, rider_id: str, operator_id: str, nominal_fare: Decimal, tap_utc: datetime
    ) -> CapCharge:
        if nominal_fare < Decimal("0.00"):
            raise ValueError(f"nominal_fare must be non-negative: {nominal_fare}")

        day_key = self.policy.service_day(tap_utc)
        state = self._state.get(rider_id)
        if state is None or state.day_key != day_key:
            state = RiderSpend(day_key=day_key)  # new day resets the daily total
            self._state[rider_id] = state

        # Headroom is the tighter of daily and (if configured) weekly caps.
        headrooms = [self.policy.daily_cap - state.day_spent]
        if self.policy.weekly_cap is not None:
            headrooms.append(self.policy.weekly_cap - state.week_spent)
        headroom = max(Decimal("0.00"), min(headrooms))

        charged = min(nominal_fare, headroom).quantize(
            Decimal("0.01"), rounding=ROUND_HALF_UP
        )

        if headroom <= Decimal("0.00"):
            outcome = CapOutcome.CAPPED
        elif charged < nominal_fare:
            outcome = CapOutcome.STRADDLE
        else:
            outcome = CapOutcome.BELOW_CAP

        state.day_spent += charged
        state.week_spent += charged
        state.tap_count += 1
        logger.info(
            "cap tap rider=%s op=%s fare=%s charged=%s outcome=%s day_spent=%s",
            rider_id, operator_id, nominal_fare, charged, outcome.value, state.day_spent,
        )
        return CapCharge(charged, outcome, state.day_spent, operator_id)

The operator_id rides along on every CapCharge because a capped multi-operator journey still has to settle: the rider paid once, but the collected total must be apportioned back to each operator whose taps contributed to it. That downstream split is where cross-operator caps feed Inter-Agency Settlement Clearing — the clearinghouse needs the per-operator charged amounts this accumulator records, not the nominal fares, so a discounted or capped ride is never over-settled.

Validation & Test Cases

The three cases that matter are a tap comfortably below the cap, the tap that crosses the cap and must be clipped, and a tap after the cap is already reached. A $7.00 daily cap with $2.90 taps reaches the cap on the third tap, which is the straddle.

from datetime import datetime, timezone
from decimal import Decimal

policy = CapPolicy(daily_cap=Decimal("7.00"), tz="America/New_York")
acc = DailyCapAccumulator(policy)
T = datetime(2026, 7, 17, 14, 0, tzinfo=timezone.utc)  # same service day

# Tap 1 & 2: below cap, full fare each -> running total 5.80
c1 = acc.apply_tap("RIDER1", "MUNI_BUS", Decimal("2.90"), T)
c2 = acc.apply_tap("RIDER1", "METRO", Decimal("2.90"), T)
assert c1.outcome is CapOutcome.BELOW_CAP
assert c2.day_spent_after == Decimal("5.80")

# Tap 3: nominal 2.90 but only 1.20 headroom left -> STRADDLE, charge 1.20
c3 = acc.apply_tap("RIDER1", "REGIONAL_RAIL", Decimal("2.90"), T)
assert c3.outcome is CapOutcome.STRADDLE
assert c3.charged == Decimal("1.20")
assert c3.day_spent_after == Decimal("7.00")

# Tap 4: already at cap -> CAPPED, free ride
c4 = acc.apply_tap("RIDER1", "MUNI_BUS", Decimal("2.90"), T)
assert c4.outcome is CapOutcome.CAPPED
assert c4.charged == Decimal("0.00")
assert c4.day_spent_after == Decimal("7.00")

# Total collected across the journey equals the cap exactly.
total = c1.charged + c2.charged + c3.charged + c4.charged
assert total == Decimal("7.00")

The load-bearing assertion is that the four charged amounts sum to exactly 7.00, not a cent more: the straddle tap contributed 1.20 (7.00 - 5.80) instead of its nominal 2.90, and the capped tap contributed nothing. Because every value is Decimal, 5.80 + 1.20 is exactly 7.00 — the same computation in float risks a 6.999... that would let a fifth tap sneak a stray cent past the cap.

Edge Cases

  • Refunds and reversals. A charge-back or a mistaken tap that is reversed must subtract from the running total, or the rider hits the cap early on the corrected day. Model reversals as negative-charge taps keyed to the same service day, and never let the total go below zero.
  • Cap changes mid-day. If a fare-relief mandate lowers the cap at noon, riders already above the new cap must not be retro-charged. Pin the policy version at the rider’s first tap of the day, the immutability rule the parent framework enforces on every threshold snapshot.
  • Concurrent taps. Two validators can read the same running total at once and both under-charge. Shard concurrency by rider_id and serialize a rider’s taps, or guard the total with an optimistic-concurrency write; never split one rider’s taps across workers.
  • Weekly cap interaction. When both caps are active the daily total resets each service day but the weekly total does not, so a rider can be below the daily cap yet capped for the week. The min over both headrooms handles this without extra branching.

Integration Note

This accumulator reads its daily_cap and weekly_cap values from the versioned snapshot published by its parent Threshold Tuning Frameworks, so a revenue team can pilot a lower cap without redeploying the calculation service. It sits immediately downstream of Transfer Window Logic: a tap that qualifies as a free transfer contributes 0.00 to the running total, so transfer evaluation must run before capping or the cap would be reached on fares the rider never actually owed. The per-operator charged amounts it records are the exact figures that cross-operator settlement consumes.

FAQ

How is the tap that straddles the cap charged?
It is charged the remaining headroom to the cap, not its full nominal fare. If the cap is $7.00, the rider has spent $5.80, and the next fare is $2.90, the charge is min(2.90, 7.00 - 5.80) = 1.20, landing the running total exactly on 7.00. Every tap after that charges 0.00. Charging the full 2.90 and refunding later is a reconciliation liability the min(fare, headroom) expression avoids entirely.
Should transfer discounts be applied before or after the cap?
Before. A free transfer contributes 0.00 to the running total, so if capping ran first the rider would reach the cap on fares they never owed and later taps would be wrongly freed. Run Transfer Window Logic first, then feed the post-transfer nominal fare into this accumulator, so the cap only ever accumulates money the rider was actually charged.
Why key the accumulator on a service day instead of the calendar day?
Because many networks roll the cap at 03:00 or 04:00 local so a single late-night journey counts as one day, not two. A tap at 01:30 belongs to the service day that started the previous morning; keying on the calendar date would split that journey across two totals and let the rider pay twice toward two separate caps. The service_day helper subtracts a day for taps before the roll time so both taps hash to the same running total.
How does a capped multi-operator journey settle back to each operator?
The accumulator records the actual charged amount per tap alongside its operator_id, and settlement apportions the collected total using those charged amounts, not the nominal fares. A tap that was clipped to the cap or freed as a capped ride must contribute only what the rider actually paid, or the clearinghouse over-settles. Those per-operator figures feed Inter-Agency Settlement Clearing.

Part of Threshold Tuning Frameworks, within Fare Rule Validation & Calculation Engines.