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.

Recompute-and-tolerance verdict for a mileage claim A claimed amount and an expected amount derived from miles multiplied by a versioned standard rate feed a tolerance band comparison of plus or minus epsilon cents. The comparison branches to three outcomes: a claim above the band is an over-claim, a claim inside the band is within band and reimbursed, and a claim below the band is an under-claim. Every verdict is written with the expected amount, claimed amount, signed variance, rate version and decision to an append-only audit ledger. Recompute, then judge against a tolerance band Versioned rate effective-dated · standard rate Claimed amount as submitted Expected amount miles × versioned rate minor units Tolerance band ± epsilon cents or basis points band? > +ε ±ε < -ε OVER_CLAIM claimed > expected WITHIN_BAND reimburse UNDER_CLAIM claimed < expected verdict logged Explainable audit record expected · claimed · signed variance · rate version · decision

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 == expected fabricates failures: 40 * 0.67 in IEEE-754 is not exactly 26.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

  1. Seed the rate history. Build the RatePeriod list 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.

  2. Recompute before you compare. Never accept the submitted amount as the baseline; call expected_minor_units for every claim and treat that as the trusted figure the rest of the stage carries forward.

  3. Size the band deliberately. Set floor_minor to absorb sub-dollar rounding and bps to bound large claims proportionally; pin both in config so an audit resolves every verdict to one band definition.

  4. Emit signed verdicts, not booleans. Route OVER_CLAIM and UNDER_CLAIM to different handlers — over-claims to policy review, under-claims to an employee-fairness correction — and pass WITHIN_BAND through to approval.

  5. 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
  6. Persist the verdict to the audit trail. Append each RateVerdict to 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.