Implementing Tiered Spending Caps in Python
Tiered spending caps produce non-deterministic verdicts whenever overlapping role, category, and per-diem limits are evaluated in implicit order against floating-point money, so the same expense line can pass one run and fail the next. This page is the concrete engine walkthrough for the parent Spending Cap Hierarchies guide: it turns that precedence model into a runnable, type-safe evaluator that resolves the binding ceiling, normalizes currency with Decimal, and routes exceptions through auditable fallback chains before payment execution.
The evaluator sits inside the broader Core Policy Architecture & Taxonomy Design control plane. It assumes the record already carries a stable category node from Expense Category Taxonomies and a location-aware allowance from Per Diem Rate Structuring, and that it cleared confidence gating in the Receipt Ingestion & OCR Data Extraction framework. It owns exactly one job: given a normalized line, compute the tightest applicable limit and emit a defensible verdict.
Why Standard Approaches Fail
Naive cap enforcement fails in production for three named reasons, each producing a silent wrong verdict rather than a visible error:
- Float drift on the cent. A
$0.01overage computed with IEEE 754 binary floats rounds a120.00limit to120.0000000001, so a compliant lodging line is hard-rejected — or a120.01line slips under. Money arithmetic must never touchfloat. - Precedence drift. When resolution order lives implicitly in evaluation sequence, a director’s Zurich lodging line is simultaneously subject to a corporate cap, a role cap, a category cap, and a per-diem override, and the code picks whichever branch happens to run first. Refactoring the loop silently re-audits a historical report to a different ceiling — the exact ambiguity the parent Spending Cap Hierarchies model exists to remove.
- Verdict without evidence. Boolean-only returns (
True/False) give downstream payment gateways and compliance queues nothing to route on and leave auditors no reason for the decision. A batch that scans rules linearly per transaction also stalls once monthly volume crosses a million lines.
Architecture & Algorithm
The evaluator below is self-contained and runnable. It models caps as frozen, tier-tagged rules, pre-indexes them into a Dict[Tuple[TierLevel, str], CapRule] for O(1) resolution, and returns a structured EvaluationResult carrying the applied tier, variance, routing action, and an immutable audit payload. Money is Decimal throughout with ROUND_HALF_UP so rounding is deterministic. Records that miss a rule or fall below the OCR confidence gate divert to a fallback verdict instead of being auto-rejected. Memory and latency notes are inline: @dataclass(slots=True) drops the per-instance __dict__ (roughly a 35% footprint cut in batch workloads), and the pre-built index keeps per-line work constant regardless of rule-set size.
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Dict, Tuple
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
from datetime import datetime, timezone
# Structured audit logging aligned with compliance retention policies
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s | %(levelname)s | %(name)s | %(message)s',
datefmt='%Y-%m-%dT%H:%M:%SZ'
)
logger = logging.getLogger("expense_cap_engine")
class TierLevel(Enum):
STANDARD = 1
MANAGER = 2
DIRECTOR = 3
EXECUTIVE = 4
class EvaluationStatus(Enum):
APPROVED = "APPROVED"
SOFT_VIOLATION = "SOFT_VIOLATION"
HARD_VIOLATION = "HARD_VIOLATION"
FALLBACK_REQUIRED = "FALLBACK_REQUIRED"
@dataclass(slots=True)
class ExpenseRecord:
expense_id: str
category: str
raw_amount: str # String input prevents float drift during ingestion
currency: str
employee_tier: TierLevel
receipt_confidence: float = 1.0
policy_version: str = "v1.0"
@dataclass(slots=True)
class CapRule:
tier: TierLevel
category: str
hard_limit: Decimal
soft_limit: Decimal
currency: str = "USD"
rule_id: str = ""
@dataclass(slots=True)
class EvaluationResult:
expense_id: str
status: EvaluationStatus
applied_tier: TierLevel
cap_limit: Decimal
actual_amount: Decimal
variance_pct: Decimal
routing_action: str
audit_payload: Dict = field(default_factory=dict)
class TieredCapEvaluator:
def __init__(self, rules: list[CapRule]):
# O(1) lookup index: (tier, category) -> CapRule
self._rule_index: Dict[Tuple[TierLevel, str], CapRule] = {
(r.tier, r.category): r for r in rules
}
self._currency_precision = Decimal("0.01")
def _parse_amount(self, raw: str) -> Decimal:
try:
amt = Decimal(raw)
return amt.quantize(self._currency_precision, rounding=ROUND_HALF_UP)
except InvalidOperation as e:
logger.error("Invalid amount format: %s", raw)
raise ValueError(f"Malformed currency string: {raw}") from e
def _resolve_rule(self, tier: TierLevel, category: str) -> Optional[CapRule]:
return self._rule_index.get((tier, category))
def evaluate(self, record: ExpenseRecord) -> EvaluationResult:
actual = self._parse_amount(record.raw_amount)
rule = self._resolve_rule(record.employee_tier, record.category)
# Fallback triggers for missing rules or low OCR confidence
if rule is None:
return self._build_fallback(record, actual, "UNMAPPED_RULE")
if record.receipt_confidence < 0.75:
return self._build_fallback(record, actual, "LOW_OCR_CONFIDENCE")
# Deterministic tier evaluation
if actual <= rule.soft_limit:
status, routing = EvaluationStatus.APPROVED, "PAYMENT_GATEWAY"
elif actual <= rule.hard_limit:
status, routing = EvaluationStatus.SOFT_VIOLATION, "MANAGER_REVIEW_QUEUE"
else:
status, routing = EvaluationStatus.HARD_VIOLATION, "COMPLIANCE_HOLD"
# Variance calculation against soft threshold
variance = ((actual - rule.soft_limit) / rule.soft_limit * 100) if rule.soft_limit else Decimal("0.00")
variance = variance.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return EvaluationResult(
expense_id=record.expense_id,
status=status,
applied_tier=record.employee_tier,
cap_limit=rule.hard_limit,
actual_amount=actual,
variance_pct=variance,
routing_action=routing,
audit_payload={
"rule_id": rule.rule_id,
"policy_version": record.policy_version,
"evaluated_at": datetime.now(timezone.utc).isoformat(),
"confidence_score": record.receipt_confidence
}
)
def _build_fallback(self, record: ExpenseRecord, actual: Decimal, reason: str) -> EvaluationResult:
logger.warning("Fallback triggered for %s: %s", record.expense_id, reason)
return EvaluationResult(
expense_id=record.expense_id,
status=EvaluationStatus.FALLBACK_REQUIRED,
applied_tier=record.employee_tier,
cap_limit=Decimal("0.00"),
actual_amount=actual,
variance_pct=Decimal("0.00"),
routing_action="AUDIT_REVIEW",
audit_payload={
"fallback_reason": reason,
"policy_version": record.policy_version,
"evaluated_at": datetime.now(timezone.utc).isoformat()
}
)
Two limits per rule give the engine three verdicts instead of a binary pass/fail: at or under soft_limit the line is APPROVED and released to the payment gateway; between the soft and hard limits it becomes a SOFT_VIOLATION routed to manager review; above hard_limit it is a HARD_VIOLATION held for compliance. The variance_pct field quantifies how far over the soft threshold a line landed, which lets Dynamic Threshold Tuning recalibrate soft limits from real breach distributions rather than guesswork. Because _resolve_rule keys on (tier, category), the winning rule is a pure lookup — no ordered scan whose sequence could drift under refactoring.
Step-by-Step Integration
-
Load the cap set once and freeze it. Build the evaluator at batch start from an immutable policy snapshot so every line in the run audits against one
policy_version; never read a cap from a mutable store mid-batch.rules = [ CapRule(TierLevel.DIRECTOR, "LODGING", Decimal("450.00"), Decimal("400.00"), "USD", "R-DIR-LODG"), CapRule(TierLevel.STANDARD, "MEALS", Decimal("75.00"), Decimal("60.00"), "USD", "R-STD-MEAL"), ] evaluator = TieredCapEvaluator(rules) -
Feed string amounts, never floats. Coerce upstream
amountfields tostrbefore constructing the record so parsing owns theDecimalconversion. Verify the cent boundary holds:ok = ExpenseRecord("E1", "MEALS", "60.00", "USD", TierLevel.STANDARD, receipt_confidence=0.98) assert evaluator.evaluate(ok).status is EvaluationStatus.APPROVED over = ExpenseRecord("E2", "MEALS", "75.01", "USD", TierLevel.STANDARD, receipt_confidence=0.98) assert evaluator.evaluate(over).status is EvaluationStatus.HARD_VIOLATION -
Divert low-confidence and unmapped lines, don’t reject them. Confirm the fallback path routes to review rather than auto-blocking so degraded scans reach a human via the receipt error categorization path:
blurry = ExpenseRecord("E3", "MEALS", "50.00", "USD", TierLevel.STANDARD, receipt_confidence=0.4) assert evaluator.evaluate(blurry).routing_action == "AUDIT_REVIEW" -
Stream, don’t materialize. Iterate records through a generator and yield each
EvaluationResultstraight to the downstream queue (Kafka, SQS) so memory stays flat regardless of batch size. -
Hot-reload atomically. For live policy edits, rebuild
_rule_indexoff to the side and swap it under athreading.Lock/asyncio.Lockso no line ever sees a half-updated rule set. -
Wire the audit stream first. Route the structured logger to your SIEM and confirm
rule_id,policy_version, andevaluated_atappear on every verdict before anyCOMPLIANCE_HOLDaction can gate a payment.
Edge Cases & Gotchas
| Edge condition | Failure it causes | Mitigation |
|---|---|---|
| Float amount reaches the evaluator | $0.01 overage misjudged by binary drift |
Accept raw_amount as str, cast to Decimal, apply ROUND_HALF_UP per the Python decimal documentation |
| Linear rule scan per transaction | Pipeline stalls on million-line batches | Pre-index rules into Dict[Tuple[TierLevel, str], CapRule] for O(1) resolution |
| Boolean-only return | Routing and audit have no reason attached | Return EvaluationResult with an immutable audit_payload (rule id, timestamp, confidence) |
| Receipt confidence below 0.75 | Rigid enforcement hard-blocks a degraded scan | Route to FALLBACK_REQUIRED / AUDIT_REVIEW instead of auto-rejection |
| Cross-currency line (EUR vs USD rule) | Amount compared against a limit in another currency | Normalize with cached, TTL-bounded FX rates before evaluation; never compute rates inline |
| Non-deterministic call in the path | Identical inputs yield different audit hashes | Keep evaluate a pure function — no random, no unseeded uuid, no wall-clock branching |
Every fallback carries an immutable audit_payload with UTC timestamps, policy versions, and the fallback reason, satisfying NIST SP 800-53 AU-2 (Audit Events) and AU-3 (Content of Audit Records). Where that payload could contain sensitive fields, scope its retention through Security & Compliance Boundaries.
FAQ
Why use two limits (soft and hard) per rule instead of one?
A single limit forces a binary pass/fail and either floods manager queues or lets marginal overages through. The soft limit marks “over policy but plausibly justified” and routes to review; the hard limit marks “block and hold.” The gap between them is where a SOFT_VIOLATION verdict and its variance_pct let you triage rather than reject.
Why Decimal instead of float for money?
Binary floating point cannot represent most decimal cent values exactly, so 0.1 + 0.2 != 0.3 and a limit comparison at the cent boundary becomes non-deterministic. Decimal with ROUND_HALF_UP makes every arithmetic step reproducible, which is a hard requirement for a verdict that must replay identically years later under audit.
How does the O(1) rule index stay correct when policy changes mid-run?
It doesn’t change mid-run — that is the point. The snapshot is frozen at batch start so the whole run audits against one policy_version. For live systems, rebuild the index off to the side and swap the reference atomically under a lock; in-flight lines finish against the old snapshot and new lines pick up the new one cleanly.
What happens to an expense that has no matching cap rule?
_resolve_rule returns None, and the record becomes FALLBACK_REQUIRED with reason UNMAPPED_RULE routed to AUDIT_REVIEW — never a silent pass. This surfaces taxonomy gaps back to Expense Category Taxonomies so the missing (tier, category) pair can be added deliberately.
How do I extend this to multiple currencies?
Normalize each line’s amount to the rule’s currency before comparison, using FX rates cached with a TTL and fetched outside the evaluation path. Keep the conversion deterministic per snapshot — pin the rate set alongside the policy_version so a re-audit reproduces the same converted amount and verdict.
Related
- Spending Cap Hierarchies — the parent guide whose precedence model this engine implements
- Building dynamic per-diem tables for global teams — produces the location-aware allowance consumed as one cap tier
- How to structure expense categories for automated auditing — supplies the stable category node the rule index keys on
- Classifying OCR extraction errors for manual review — where low-confidence fallbacks divert
- Dynamic Threshold Tuning — recalibrates soft limits from observed breach variance