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 when the rider has already spent against a daily cap , the amount actually charged is the fare or the remaining headroom, whichever is smaller, floored at zero:
Below the cap, so the rider pays the full . On the straddling tap, so the rider pays only the headroom and the running total lands exactly on . Once , every remaining tap charges . The same shape extends to a weekly cap : charge against the tighter of the two headrooms, .
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.
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_idand 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
minover 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?
$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?
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?
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?
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.
Related
- Threshold Tuning Frameworks — the parent that publishes the versioned daily and weekly cap values this accumulator reads
- Transfer Window Logic — free transfers that must be resolved before a tap accumulates toward the cap
- Inter-Agency Settlement Clearing — where per-operator charged amounts from a capped journey settle
- Discount Eligibility Engines — concession discounts that lower the nominal fare feeding the cap
- Dynamic Peak Pricing Threshold Adjustment Scripts — the sibling optimizer that recalibrates these boundaries
Part of Threshold Tuning Frameworks, within Fare Rule Validation & Calculation Engines.