Building fallback MCC taxonomy rules for unmapped merchants
When a transaction arrives with a missing or unrecognized merchant category code, the router has nothing to look up, and without a deterministic fallback the record either stalls in a review queue or silently inherits a permissive default. This guide is the missing-code detail delegated by the Merchant Category Code Routing stage inside the broader Automated Policy Validation & Anomaly Flagging framework. Here we build the fallback taxonomy the router reaches for when a code is absent or unknown: an ordered set of exact-override, prefix, and numeric-range rules backed by a mandatory root default, so every unmapped merchant still leaves with an explainable category and full provenance.
The design principle is total coverage with honest specificity: a fallback never claims more precision than its rule warrants, and the least-specific catch-all is a review category, not a permissive pass.
Why Standard Approaches Fail
A missing or unknown code is not a rare edge — acquirers routinely omit codes on card-not-present charges, new merchants surface before your table knows them, and OCR-derived records often carry no code at all. Three named failure modes turn that gap into leaked spend:
- Permissive default bucket. Dropping unmapped merchants into a generic “other” category that happens to be allowlisted lets out-of-policy spend post unreviewed, exactly the catch-all drift the parent Merchant Category Code Routing stage escalates rather than approves. A fallback that resolves to a permissive category is worse than no fallback.
- Flat lookup with no hierarchy. A single dictionary of known codes returns nothing for
5811when only5812is registered, even though both are dining codes one digit apart. Without prefix and range rules, near-neighbours of known codes fall through to review in volumes large enough to swamp the queue, and the taxonomy never generalizes. - Non-deterministic tie handling. When several fallback patterns could match — a prefix rule and a range rule both covering
5814— an implementation that picks whichever was inserted first produces category assignments that depend on config load order. The same code resolves differently across deploys, and the audit trail cannot explain why.
The remedy is an ordered taxonomy where every rule declares its own specificity, the resolver always applies the most specific match, and a mandatory root default guarantees total coverage so no record can escape without an auditable category.
Architecture & Algorithm
A robust fallback taxonomy ranks rules by an explicit specificity tier — exact code, then prefix, then numeric range, then a root default that always matches — and evaluates them most-specific-first so the result never depends on config order. The constructor refuses to build a taxonomy that lacks a root default, which is what makes total coverage a structural guarantee rather than a hope. Every resolution carries the matching rule’s id, its specificity, and the concrete matched pattern, plus a deterministic hash for audit replay.
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import IntEnum
from typing import Optional
import structlog
logger = structlog.get_logger("expense.mcc_fallback")
POLICY_VERSION = "mcc-fallback/2026.07"
class Specificity(IntEnum):
"""Higher tiers are evaluated first; DEFAULT always matches last."""
EXACT = 4
PREFIX = 3
RANGE = 2
DEFAULT = 1
@dataclass(frozen=True)
class FallbackRule:
rule_id: str
specificity: Specificity
category: str
exact_code: Optional[str] = None
prefix: Optional[str] = None
range_lo: Optional[int] = None
range_hi: Optional[int] = None
def matches(self, code: Optional[str]) -> bool:
if self.specificity is Specificity.DEFAULT:
return True
if code is None:
return False
if self.specificity is Specificity.EXACT:
return code == self.exact_code
if self.specificity is Specificity.PREFIX:
return self.prefix is not None and code.startswith(self.prefix)
if self.specificity is Specificity.RANGE:
if self.range_lo is None or self.range_hi is None or not code.isdigit():
return False
return self.range_lo <= int(code) <= self.range_hi
return False
@dataclass(frozen=True)
class FallbackDecision:
mcc_code: Optional[str]
category: str
rule_id: str
specificity: int
matched_pattern: str
detail: str
policy_version: str
decision_hash: str
decided_at_utc: str
The resolver freezes traversal order once, verifies the root default exists at construction time, and returns the first matching rule. Because tiers are ordered by specificity and ties within a tier break on rule_id, the outcome is fully determined by the rule set and the code — never by insertion order.
class FallbackTaxonomy:
"""Deterministic default taxonomy for missing or unknown MCCs.
Rules are evaluated most-specific-first; a mandatory root default
guarantees every record leaves with an auditable category.
"""
def __init__(
self,
rules: list[FallbackRule],
*,
policy_version: str = POLICY_VERSION,
) -> None:
ordered = sorted(rules, key=lambda r: (-int(r.specificity), r.rule_id))
if not any(r.specificity is Specificity.DEFAULT for r in ordered):
raise ValueError("taxonomy must define a DEFAULT root rule for total coverage")
self._rules = ordered
self._policy_version = policy_version
def resolve(self, mcc_code: Optional[str]) -> FallbackDecision:
for rule in self._rules:
if rule.matches(mcc_code):
pattern = self._pattern(rule)
return self._decide(mcc_code, rule, pattern,
f"matched {rule.specificity.name} rule {rule.rule_id}")
# Unreachable: the constructor guarantees a DEFAULT rule that always matches.
raise RuntimeError("no fallback rule matched and no root default present")
@staticmethod
def _pattern(rule: FallbackRule) -> str:
if rule.specificity is Specificity.EXACT:
return str(rule.exact_code)
if rule.specificity is Specificity.PREFIX:
return f"{rule.prefix}*"
if rule.specificity is Specificity.RANGE:
return f"{rule.range_lo}-{rule.range_hi}"
return "root"
def _decide(
self,
mcc_code: Optional[str],
rule: FallbackRule,
pattern: str,
detail: str,
) -> FallbackDecision:
fingerprint = "|".join([str(mcc_code), rule.rule_id, rule.category,
pattern, self._policy_version])
decision_hash = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
decision = FallbackDecision(
mcc_code=mcc_code, category=rule.category, rule_id=rule.rule_id,
specificity=int(rule.specificity), matched_pattern=pattern,
detail=detail, policy_version=self._policy_version,
decision_hash=decision_hash,
decided_at_utc=datetime.now(timezone.utc).isoformat(),
)
logger.info("mcc_fallback_resolved", mcc_code=mcc_code,
category=rule.category, rule_id=rule.rule_id,
specificity=int(rule.specificity), matched_pattern=pattern,
decision_hash=decision_hash, policy_version=self._policy_version)
return decision
Modeling specificity as an IntEnum keeps the ordering explicit and self-documenting, and folding the category and pattern into the decision_hash means a corrected taxonomy under a new policy_version produces a distinct, reconstructable fingerprint while an unchanged one replays identically.
Step-by-Step Integration
-
Invoke only after a
MISSING_MCCor unknown-code outcome. The Merchant Category Code Routing engine calls the taxonomy only when it has no code to route; a present, known code never touches this path. Where a code is present but maps to several categories, use resolving ambiguous MCC mappings instead. -
Anchor categories to the canonical taxonomy. Every
categorya fallback rule emits must be a real node in Expense Category Taxonomies, including theUNCATEGORIZED_REVIEWroot, so downstream policy checks recognize the label. Keep that taxonomy the source of truth; the fallback only selects from it. -
Order rules by specificity, not by hand. Let the constructor sort by tier; author exact overrides for codes you know, prefix rules for code families, and range rules for contiguous blocks, then a single root default. Confirm the resolver honours the cascade and stays deterministic:
taxonomy = FallbackTaxonomy([ FallbackRule("exact-rideshare", Specificity.EXACT, "GROUND_TRANSPORT", exact_code="4121"), FallbackRule("prefix-58xx", Specificity.PREFIX, "MEALS_UNVERIFIED", prefix="58"), FallbackRule("range-airlines", Specificity.RANGE, "AIR_TRAVEL", range_lo=3000, range_hi=3299), FallbackRule("root", Specificity.DEFAULT, "UNCATEGORIZED_REVIEW"), ]) assert taxonomy.resolve("4121").category == "GROUND_TRANSPORT" # exact override assert taxonomy.resolve("5814").category == "MEALS_UNVERIFIED" # prefix beats range assert taxonomy.resolve("3005").category == "AIR_TRAVEL" # numeric range assert taxonomy.resolve(None).category == "UNCATEGORIZED_REVIEW" # missing code assert taxonomy.resolve("9999").rule_id == "root" # unknown -> default assert taxonomy.resolve("5814").decision_hash == taxonomy.resolve("5814").decision_hash -
Never allowlist the root default. Wire
UNCATEGORIZED_REVIEWto a reviewer queue, not an auto-approve path; the default exists to guarantee coverage, not to wave spend through. Anything that lands there is an unmapped merchant a human must classify. -
Feed reviewer decisions back as new exact overrides. When a reviewer categorizes a formerly unmapped merchant, add an
EXACTrule so the next occurrence resolves without review, shrinking the default bucket over time. -
Append every resolution to the ledger. Mirror each
structlogevent into the same append-only ledger the router writes, sorule_id,specificity,matched_pattern, anddecision_hashexplain exactly which fallback fired and let an auditor reconstruct the taxonomy active at processing time.
Edge Cases & Gotchas
| Edge condition | What breaks | Mitigation |
|---|---|---|
| Missing root default | A code matching no rule raises at runtime instead of routing | Constructor rejects any taxonomy without a DEFAULT rule — total coverage is enforced structurally |
| Overlapping prefix and range | Both cover 5814, and order-dependent code would pick either |
Specificity tiers decide: PREFIX outranks RANGE, so the outcome is deterministic |
| Non-numeric or malformed code | int(code) would raise inside a range check |
matches guards with code.isdigit() and returns False on malformed input, cascading down |
| Over-broad prefix rule | A 5 prefix swallows every retail code into one bucket |
Keep prefixes at family granularity (two–three digits); prefer explicit ranges for wide blocks |
| Growing default bucket | An expanding UNCATEGORIZED_REVIEW share signals table rot |
Alert on default-rate drift and promote recurring reviewer decisions to exact overrides |
| Silent taxonomy edits | Changing a rule without bumping the version breaks audit reconstruction | Fold category and matched_pattern into policy_version; never edit rules in place without a bump |
FAQ
When does the fallback taxonomy run instead of normal routing?
It runs only when the routing engine has no usable code — the transaction’s MCC is null, malformed, or absent from every routing rule. If a code is present and valid but maps to more than one category, that is a disambiguation problem handled by resolving ambiguous MCC mappings, not the fallback taxonomy. The two paths are mutually exclusive by input.
Why order rules by specificity instead of by insertion?
Insertion order makes resolution depend on how the config file was assembled, so the same code can resolve differently across deploys and the audit trail cannot explain the change. Ranking by an explicit specificity tier — exact, then prefix, then range, then default — makes the outcome a pure function of the rule set and the code. It also matches intuition: a rule that names one code should always beat a rule that covers a whole family.
What category should the root default map to?
Map it to a review category such as UNCATEGORIZED_REVIEW that is never allowlisted, so unmapped merchants reach a human rather than posting unreviewed. The root default guarantees total coverage — every record leaves with a category — but its purpose is to route the unknown to triage, not to grant a permissive pass. Treating the default as auto-approvable reintroduces exactly the catch-all leak the taxonomy exists to close.
How do I keep the default bucket from growing unbounded?
Monitor the share of records resolving to the root default over time and alert on upward drift, which usually signals a stale rule set or a new acquirer feed. Each time a reviewer classifies a merchant that fell through, promote that decision into an exact-code override so the next occurrence resolves automatically. Over a few cycles the default bucket shrinks to genuinely novel merchants only.
Can prefix and range rules both cover the same code?
Yes, and that overlap is expected — a 58 prefix and a 5800–5899 range can both match 5814. The specificity ordering resolves it deterministically: the prefix tier outranks the range tier, so the prefix rule wins regardless of config order. If you want the range to win in some sub-case, express it as a narrower prefix or a more specific exact override rather than relying on ordering.
Related
- Merchant Category Code Routing — the parent guide that invokes this taxonomy on missing or unknown codes
- Resolving ambiguous MCC mappings for expense routing — the sibling path for codes present but mapped to several categories
- Expense Category Taxonomies — the canonical category tree every fallback rule must draw from
- Dynamic Threshold Tuning — the downstream spend-band gate that trusts the resolved category
- Duplicate Receipt Detection — reconciles records whose codes disagree across feeds