Validating mileage claims against standard reimbursement rates
A mileage claim is wrong whenever the submitted amount drifts from miles × the standard rate in force on the trip date by more than a defined tolerance — and the only reliable way to catch it is to recompute the amount yourself rather than trust the figure a portal or spreadsheet produced. This guide is the deterministic recompute detail delegated by the Mileage Reimbursement Validation guide, which sits inside the broader Automated Policy Validation & Anomaly Flagging framework. The parent guide owns route plausibility, per-mile caps, and duplicate suppression; here we own one thing precisely: reproduce the reimbursable amount from miles and a versioned rate, compare it against the claim inside an explicit tolerance band, and emit an explainable over-claim or under-claim verdict that an auditor can replay.
Why Standard Approaches Fail
Naive mileage checks share one flaw: they compare against a number instead of reconstructing it, so they miss the errors that matter most.
- Trusting the portal’s total. Expense tools let an employee enter both the mileage and the amount, or apply a rate the tool has hardcoded. A check that verifies the amount is “present and positive” never notices that it was computed with last year’s rate. Only recomputing the amount from miles and the correct effective-dated rate exposes the drift, and because the standard mileage rate changes on a fixed schedule — the same effective-dated rate structuring problem that per diem tables face — the wrong-rate error is systematic across every claim filed in the changeover window.
- Exact-equality comparison. Recomputing with floating-point dollars and then testing
claimed == expectedfabricates failures:40 * 0.67in IEEE-754 is not exactly26.80, so an honest claim shows a fraction-of-a-cent variance and floods the review queue. The fix is a tolerance band evaluated in minor-unit integers, wide enough to absorb legitimate rounding but no wider. - Unsigned verdicts. A check that emits only “mismatch” tells a reviewer nothing actionable. Whether the employee over-claimed (a policy and cost concern) or under-claimed (an employee-fairness concern, often from a stale low rate) demands different handling, and a corrupted trip date silently selects the wrong rate period — the boundary arithmetic owned by Date Window Validation Logic. The verdict must carry a signed variance so the direction and magnitude are explicit.
Architecture & Algorithm
The algorithm is deliberately small: resolve the rate by trip date, recompute the expected amount in minor units, then classify the signed variance against a tolerance band expressed as an absolute cent floor combined with a proportional basis-points allowance. Using the larger of the two bounds keeps small claims from being over-policed by a flat cent tolerance while still constraining large ones proportionally. Every branch produces a structured, replayable result.
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import List, Sequence, Tuple
logger = logging.getLogger("expense.mileage.standard_rate")
@dataclass(frozen=True)
class RatePeriod:
effective_start: date
effective_end: date
rate_per_mile: Decimal
version: str
@dataclass(frozen=True)
class RateVerdict:
verdict: str # WITHIN_BAND | OVER_CLAIM | UNDER_CLAIM
expected_minor: int
claimed_minor: int
variance_minor: int # signed: claimed - expected
band_minor: int
rate_version: str
evaluated_at: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
def resolve_rate(periods: Sequence[RatePeriod], on: date) -> RatePeriod:
"""Deterministically select the rate period covering the trip date."""
for period in sorted(periods, key=lambda p: p.effective_start):
if period.effective_start <= on <= period.effective_end:
return period
raise LookupError(f"no standard rate on file for {on.isoformat()}")
def expected_minor_units(miles: Decimal, rate: RatePeriod) -> int:
"""Reimbursable amount = miles * rate, quantized once to the cent."""
dollars = (miles * rate.rate_per_mile).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
return int(dollars * 100)
def tolerance_band_minor(expected_minor: int, floor_minor: int, bps: int) -> int:
"""Larger of an absolute cent floor and a proportional basis-points band."""
proportional = (Decimal(expected_minor) * Decimal(bps) / Decimal(10_000)).to_integral_value(
rounding=ROUND_HALF_UP
)
return max(floor_minor, int(proportional))
def classify_claim(
miles: Decimal,
claimed_minor: int,
trip_date: date,
periods: Sequence[RatePeriod],
floor_minor: int = 50,
bps: int = 100,
) -> RateVerdict:
"""Recompute and classify a mileage claim against the standard rate."""
rate = resolve_rate(periods, trip_date)
expected = expected_minor_units(miles, rate)
band = tolerance_band_minor(expected, floor_minor, bps)
variance = claimed_minor - expected
if variance > band:
verdict = "OVER_CLAIM"
elif variance < -band:
verdict = "UNDER_CLAIM"
else:
verdict = "WITHIN_BAND"
result = RateVerdict(
verdict=verdict,
expected_minor=expected,
claimed_minor=claimed_minor,
variance_minor=variance,
band_minor=band,
rate_version=rate.version,
)
logger.info(
"standard_rate_verdict",
extra={
"verdict": verdict,
"variance_minor": variance,
"band_minor": band,
"rate_version": rate.version,
},
)
return result
The verdict is fully explainable: expected_minor and band_minor show exactly what was reimbursable and how much slack was allowed, variance_minor shows direction and magnitude, and rate_version pins the figure used — everything a reviewer or auditor needs to reconstruct the decision without rerunning the pipeline.
Step-by-Step Integration
-
Seed the rate history. Build the
RatePeriodlist from every effective-dated standard rate the organization honors, including prior years, so a late-filed claim resolves to the historical rate rather than raising a lookup error. -
Recompute before you compare. Never accept the submitted amount as the baseline; call
expected_minor_unitsfor every claim and treat that as the trusted figure the rest of the stage carries forward. -
Size the band deliberately. Set
floor_minorto absorb sub-dollar rounding andbpsto bound large claims proportionally; pin both in config so an audit resolves every verdict to one band definition. -
Emit signed verdicts, not booleans. Route
OVER_CLAIMandUNDER_CLAIMto different handlers — over-claims to policy review, under-claims to an employee-fairness correction — and passWITHIN_BANDthrough to approval. -
Verify the classifier before wiring it in. Confirm the three branches and the rounding edge with assertions:
from datetime import date from decimal import Decimal periods = [ RatePeriod(date(2024, 1, 1), date(2024, 12, 31), Decimal("0.67"), "irs-2024"), RatePeriod(date(2023, 1, 1), date(2023, 12, 31), Decimal("0.655"), "irs-2023"), ] # Honest 2024 claim: 40 * 0.67 = 26.80 -> reconciles within band. ok = classify_claim(Decimal("40"), 2680, date(2024, 3, 1), periods) assert ok.verdict == "WITHIN_BAND" and ok.variance_minor == 0 # Same miles paid at the stale 2023 rate -> under-claim of 60 cents. stale = classify_claim(Decimal("40"), 2620, date(2024, 3, 1), periods) assert stale.verdict == "UNDER_CLAIM" and stale.variance_minor == -60 # Padded amount well beyond the band -> over-claim. padded = classify_claim(Decimal("40"), 4000, date(2024, 3, 1), periods) assert padded.verdict == "OVER_CLAIM" and padded.variance_minor > padded.band_minor -
Persist the verdict to the audit trail. Append each
RateVerdictto the same append-only ledger the parent stage writes, so the recompute inputs and rate version link into the chain of custody for the claim.
Edge Cases & Gotchas
| Edge condition | What breaks | Mitigation |
|---|---|---|
| Trip date on a rate boundary | The changeover day resolves to the adjacent period and applies the wrong rate | Make effective_end inclusive and test the exact boundary date; align with date-window validation |
| Float dollars in the recompute | 40 * 0.67 is not exactly 26.80, fabricating a cent of variance |
Multiply and quantize in Decimal, then store and compare in minor-unit integers |
| Flat band over-policing small claims | A 5-mile claim has almost no slack under a basis-points-only band | Combine an absolute cent floor with the proportional band and use the larger |
| Missing rate period | A prior-year or future-dated claim raises LookupError mid-batch |
Seed full rate history and fail the claim to review rather than crashing the batch |
| Unsigned mismatch verdict | Reviewer cannot tell over-claim from under-claim | Carry a signed variance_minor and branch the routing on its sign |
| Fractional miles rounding | Odometer readings like 12.4 miles round inconsistently across tools | Keep miles as Decimal end to end and quantize only the final dollar amount |
FAQ
Why recompute the amount instead of validating the one submitted?
Because the submitted amount is exactly what you cannot trust: it may have been produced with a stale rate, a rounding bug, or a manual override. Recomputing miles × the effective-dated rate gives you an independent baseline, and the difference between that baseline and the claim is the finding. Validating the submitted figure against itself catches nothing.
How wide should the tolerance band be?
Wide enough to absorb legitimate rounding, no wider. A common setup is an absolute floor around 50 cents combined with a proportional band of about 100 basis points, taking the larger of the two so small claims are not over-policed while large claims stay proportionally constrained. Pin the chosen values in config so every verdict resolves to one band definition during an audit.
Which rate applies when a claim is filed weeks after the trip?
The rate in force on the trip date, not the submission date. Resolving by trip date against a versioned rate history is what stops a claim entered in January from being paid at the wrong figure when the standard rate changed at year end. This is why the rate table must retain prior-year periods rather than only the current one.
Should an under-claim be flagged or silently paid?
Flag it. An under-claim usually means the employee was shortchanged — often by a stale low rate in a submission tool — and paying it silently at the low figure bakes in an unfair result. Emitting a signed UNDER_CLAIM verdict lets AP route it to a fairness correction rather than treating any deviation as a violation.
How does this stay auditable?
Every verdict carries the expected amount, the claimed amount, the signed variance, the band that was applied, and the pinned rate version, written to an append-only ledger. That record lets an auditor replay the decision from first principles without rerunning the pipeline, which is the difference between an explainable control and an opaque flag.
Related
- Mileage Reimbursement Validation — the parent guide that owns route plausibility, per-mile caps, and duplicate suppression
- Validating expense dates against corporate travel policies — the trip-date boundary arithmetic that selects the correct rate period
- Building dynamic per diem tables for global teams — the sibling pattern for effective-dated allowance tables