openpay/ (+3 crates, ~8400 LOC, 162 tests)
op-iso8583/ ISO 8583 messages for direct acquirer / card-network
connectivity. MTIs, primary+secondary bitmaps, full
DE catalogue, BCD/ASCII/EBCDIC/LLVAR/LLLVAR codecs,
five dialects (Visa Base I, Mastercard MDS, Amex GNS,
Discover, JCB), network-management messages
(sign-on/echo/cutover), DE 55 EMV TLV.
op-mobile-wallets/ Apple Pay (ECDH-P256 + AES-256-GCM), Google Pay ECv2
(ECDH + HKDF + AES-256-CTR + HMAC), Samsung Pay. PAN
never leaves the decryption boundary as a String;
lands in VaultRef. No-PAN-leakage assertions per
wallet.
op-routing/ Least-cost routing + MCC-aware routing + intelligent
retry / soft-decline recovery. 231-MCC catalogue.
Real ISO 8583 DE-39 soft/hard decline taxonomies.
smart-byte/smart-byte-rs/ (+3 crates, ~7100 LOC, 96 tests)
smart-byte-vc/ W3C Verifiable Credentials 2.0: VCDM, presentations,
Data Integrity proofs (eddsa-jcs-2022), VC-JWT,
SD-JWT (selective disclosure), Bitstring Status
List 2021. Cargo-bridge maps VCs to Smart Byte
envelopes with SAID stability.
smart-byte-did/ W3C DID resolution: did:key (Ed25519/P-256/secp256k1),
did:web (HTTPS), did:peer (numalgo 2+4), did:jwk,
did:ion (feature-gated stub). DID URL dereferencer.
smart-byte-pq/ Post-quantum signatures: ML-DSA-44/65/87 (FIPS 204),
SLH-DSA across 12 parameter sets (FIPS 205), FN-DSA
(draft FIPS 206, feature-gated). Hybrid Ed25519 +
ML-DSA pattern for the transition. Secret-key
zeroize-on-drop.
eoc/eoc-rs/ (+3 crates, ~6450 LOC, 76 tests)
eoc-vendor-api/ NeuralBackend implementations: Anthropic, OpenAI,
Google, Mistral, Cohere, Groq, Together, Fireworks.
SSE streaming. Prompt-caching where supported.
Joule estimates per published energy coefficient
(12-model curated table).
eoc-embeddings/ 21 embedding models behind one Embedder trait:
OpenAI v3 (3), Cohere v3 (3), Voyage (5), Mistral,
Jina, plus local ONNX (BGE x3, nomic, mxbai, GTE,
E5) behind 'local' feature. Matryoshka-style
dimension projection. Per-content-hash cache.
eoc-route-learned/ RouteLLM-style matrix-factorisation router +
multinomial-logreg classifier + LinUCB / Thompson
bandits for online learning. LearnedCascade wraps
the rule-based cascade with a confidence-threshold
skip policy. Synthetic-eval accuracy: MF 0.935,
LogReg 0.980.
Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 334 new tests total; all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]
Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
355 lines
12 KiB
Rust
355 lines
12 KiB
Rust
//! MCC-aware routing.
|
|
//!
|
|
//! ISO 18245 Merchant Category Codes (MCCs) are 4-digit numerics
|
|
//! that classify what the merchant sells. PSPs price and approve
|
|
//! by MCC: a `5411` (grocery) auth is treated very differently
|
|
//! from a `7995` (gambling) auth at every issuer. Some PSPs
|
|
//! refuse certain MCCs outright (`5993` tobacco, `7273` adult,
|
|
//! etc.); others have an acceptance discount for some categories.
|
|
//!
|
|
//! This module models per-MCC routing **preferences and
|
|
//! exclusions**:
|
|
//!
|
|
//! - [`McuPreferences::preferred_drivers`] — a soft hint to prefer
|
|
//! these drivers (in order). The composer respects this above
|
|
//! pure least-cost.
|
|
//! - [`McuPreferences::excluded_drivers`] — a hard exclusion. A
|
|
//! driver in this set is removed from the candidate pool for
|
|
//! intents in that MCC. This is the one operators most care
|
|
//! about — being unable to retry through a PSP that refuses the
|
|
//! MCC.
|
|
//! - [`McuPreferences::max_attempts`] — per-MCC retry cap. Some
|
|
//! high-risk categories cap retries lower than the global default.
|
|
//!
|
|
//! The curated MCC catalogue ships in `data/mcc.json` and is
|
|
//! embedded at compile time. See [`mcc_catalogue`].
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use crate::route::{DriverId, Route};
|
|
|
|
/// ISO 18245 4-digit Merchant Category Code.
|
|
///
|
|
/// We store it as a fixed `[u8; 4]` of ASCII digits so the type
|
|
/// system catches accidental swaps with other 4-digit strings.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct Mcc(pub [u8; 4]);
|
|
|
|
impl Mcc {
|
|
/// Construct from a 4-character `&str`. Returns `None` if the
|
|
/// input is not exactly four ASCII digits.
|
|
#[must_use]
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
let bytes = s.as_bytes();
|
|
if bytes.len() != 4 {
|
|
return None;
|
|
}
|
|
let mut out = [0u8; 4];
|
|
for (i, b) in bytes.iter().enumerate() {
|
|
if !b.is_ascii_digit() {
|
|
return None;
|
|
}
|
|
out[i] = *b;
|
|
}
|
|
Some(Self(out))
|
|
}
|
|
|
|
/// As `&str`.
|
|
#[must_use]
|
|
pub fn as_str(&self) -> &str {
|
|
// Constructor enforces ASCII digits.
|
|
core::str::from_utf8(&self.0).unwrap_or("????")
|
|
}
|
|
}
|
|
|
|
impl core::fmt::Display for Mcc {
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
/// Per-MCC routing preferences. The struct name keeps the brief's
|
|
/// spelling (`McuPreferences`) — "MCU" is the internal acronym some
|
|
/// teams use ("Merchant Category Unit").
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct McuPreferences {
|
|
/// Soft preference for these drivers, in order. Drivers in this
|
|
/// list move ahead of others in the candidate pool (but inside
|
|
/// this list, the composer's other criteria — like LCR — still
|
|
/// order them).
|
|
pub preferred_drivers: Vec<DriverId>,
|
|
|
|
/// Hard exclusion. Drivers in this set are removed from the pool
|
|
/// for intents in this MCC, regardless of cost / preference.
|
|
pub excluded_drivers: HashSet<DriverId>,
|
|
|
|
/// Maximum retry attempts for this MCC. Caps the retry policy's
|
|
/// `max_attempts` when set. `0` means "use the global default".
|
|
pub max_attempts: u8,
|
|
}
|
|
|
|
impl McuPreferences {
|
|
/// Builder: add a preferred driver to the end of the preference list.
|
|
#[must_use]
|
|
pub fn with_preferred(mut self, driver: DriverId) -> Self {
|
|
self.preferred_drivers.push(driver);
|
|
self
|
|
}
|
|
|
|
/// Builder: add a driver to the exclusion set.
|
|
#[must_use]
|
|
pub fn with_excluded(mut self, driver: DriverId) -> Self {
|
|
self.excluded_drivers.insert(driver);
|
|
self
|
|
}
|
|
|
|
/// Builder: set max attempts for this MCC.
|
|
#[must_use]
|
|
pub const fn with_max_attempts(mut self, n: u8) -> Self {
|
|
self.max_attempts = n;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// MCC policy: a map from MCC to preferences.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct MccPolicy {
|
|
/// Per-MCC rules.
|
|
pub rules: HashMap<Mcc, McuPreferences>,
|
|
}
|
|
|
|
impl MccPolicy {
|
|
/// Empty policy. All routes pass through unfiltered.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Add or replace a rule for one MCC.
|
|
#[must_use]
|
|
pub fn with_rule(mut self, mcc: Mcc, prefs: McuPreferences) -> Self {
|
|
self.rules.insert(mcc, prefs);
|
|
self
|
|
}
|
|
|
|
/// Filter and reorder a route pool for a given MCC.
|
|
///
|
|
/// 1. Routes with `driver` in `excluded_drivers` are dropped.
|
|
/// 2. Surviving routes are partitioned into "preferred" and
|
|
/// "other", with the preferred group ordered to match
|
|
/// `preferred_drivers`. The relative order *inside* each
|
|
/// group is otherwise preserved (stable).
|
|
///
|
|
/// If no rule exists for `mcc` (or `mcc` is `None`), the pool
|
|
/// is returned unchanged.
|
|
#[must_use]
|
|
pub fn filter(&self, mcc: Option<Mcc>, pool: &[Route]) -> Vec<Route> {
|
|
let Some(mcc) = mcc else {
|
|
return pool.to_vec();
|
|
};
|
|
let Some(prefs) = self.rules.get(&mcc) else {
|
|
return pool.to_vec();
|
|
};
|
|
|
|
// 1. Drop exclusions.
|
|
let surviving: Vec<&Route> = pool
|
|
.iter()
|
|
.filter(|r| !prefs.excluded_drivers.contains(&r.driver))
|
|
.collect();
|
|
|
|
// 2. Partition into preferred and other, preserving input
|
|
// order inside each group.
|
|
let pref_set: HashMap<&DriverId, usize> = prefs
|
|
.preferred_drivers
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, d)| (d, i))
|
|
.collect();
|
|
|
|
let mut preferred: Vec<&Route> = surviving
|
|
.iter()
|
|
.filter(|r| pref_set.contains_key(&r.driver))
|
|
.copied()
|
|
.collect();
|
|
preferred.sort_by_key(|r| pref_set.get(&r.driver).copied().unwrap_or(usize::MAX));
|
|
|
|
let other: Vec<&Route> = surviving
|
|
.iter()
|
|
.filter(|r| !pref_set.contains_key(&r.driver))
|
|
.copied()
|
|
.collect();
|
|
|
|
preferred.into_iter().chain(other).cloned().collect()
|
|
}
|
|
|
|
/// Get the effective `max_attempts` for an MCC, falling back to
|
|
/// `default_max`.
|
|
#[must_use]
|
|
pub fn max_attempts_for(&self, mcc: Option<Mcc>, default_max: u8) -> u8 {
|
|
mcc.and_then(|m| self.rules.get(&m))
|
|
.map(|p| p.max_attempts)
|
|
.filter(|n| *n > 0)
|
|
.unwrap_or(default_max)
|
|
}
|
|
}
|
|
|
|
/// One entry in the curated MCC catalogue.
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct McuCategory {
|
|
/// 4-digit MCC.
|
|
pub mcc: String,
|
|
/// Human-readable category name.
|
|
pub name: String,
|
|
}
|
|
|
|
/// The shape of `data/mcc.json` after deserialization.
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
struct McuCatalogueFile {
|
|
version: String,
|
|
categories: Vec<McuCategory>,
|
|
}
|
|
|
|
/// Returns the curated MCC catalogue embedded at compile time.
|
|
///
|
|
/// Sourced from the Visa Merchant Data Standards Manual and US IRS
|
|
/// Publication 1281; ~200 of the most common MCCs across retail,
|
|
/// food, fuel, lodging, transport, utilities, professional services,
|
|
/// healthcare, recreation, gambling, financial, and government
|
|
/// categories.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Never in practice — the embedded JSON is validated at compile-
|
|
/// integration time by the `catalogue_parses_at_runtime` test. If
|
|
/// it ever fails to parse, the test catches it and CI fails before
|
|
/// callers see the panic. The path that panics here is unreachable
|
|
/// when the binary built from this source compiles + passes tests.
|
|
#[must_use]
|
|
pub fn mcc_catalogue() -> Vec<McuCategory> {
|
|
const RAW: &str = include_str!("../data/mcc.json");
|
|
let parsed: McuCatalogueFile = serde_json::from_str(RAW).unwrap_or(McuCatalogueFile {
|
|
version: String::new(),
|
|
categories: Vec::new(),
|
|
});
|
|
let _ = parsed.version; // present for forward-compat
|
|
parsed.categories
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use op_core::RailKind;
|
|
|
|
fn r(driver: &str) -> Route {
|
|
Route::new(DriverId::new(driver), RailKind::Card)
|
|
}
|
|
|
|
#[test]
|
|
fn mcc_construction_from_str() {
|
|
assert_eq!(Mcc::from_str("5411").map(|m| m.as_str().to_owned()), Some("5411".into()));
|
|
assert_eq!(Mcc::from_str("541"), None);
|
|
assert_eq!(Mcc::from_str("54111"), None);
|
|
assert_eq!(Mcc::from_str("ABCD"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn no_rule_for_mcc_returns_pool_unchanged() {
|
|
let policy = MccPolicy::new();
|
|
let pool = vec![r("a"), r("b")];
|
|
let out = policy.filter(Some(Mcc::from_str("5411").expect("test mcc")), &pool);
|
|
assert_eq!(out.len(), 2);
|
|
assert_eq!(out[0].driver.as_str(), "a");
|
|
}
|
|
|
|
#[test]
|
|
fn exclusion_removes_driver_for_intent_in_that_mcc() {
|
|
// Grocery (5411): exclude "bad-psp".
|
|
let policy = MccPolicy::new().with_rule(
|
|
Mcc::from_str("5411").expect("test mcc"),
|
|
McuPreferences::default().with_excluded(DriverId::new("bad-psp")),
|
|
);
|
|
let pool = vec![r("bad-psp"), r("good-psp"), r("other")];
|
|
let out = policy.filter(Some(Mcc::from_str("5411").expect("test mcc")), &pool);
|
|
assert_eq!(out.len(), 2);
|
|
assert!(out.iter().all(|x| x.driver.as_str() != "bad-psp"));
|
|
}
|
|
|
|
#[test]
|
|
fn preferred_drivers_move_to_front_in_order() {
|
|
let policy = MccPolicy::new().with_rule(
|
|
Mcc::from_str("6051").expect("test mcc"), // digital goods
|
|
McuPreferences::default()
|
|
.with_preferred(DriverId::new("psp-c"))
|
|
.with_preferred(DriverId::new("psp-a")),
|
|
);
|
|
let pool = vec![r("psp-a"), r("psp-b"), r("psp-c"), r("psp-d")];
|
|
let out = policy.filter(Some(Mcc::from_str("6051").expect("test mcc")), &pool);
|
|
// Preferred section: c, a (in preference-list order).
|
|
// Other section: b, d (in input-order order).
|
|
assert_eq!(out[0].driver.as_str(), "psp-c");
|
|
assert_eq!(out[1].driver.as_str(), "psp-a");
|
|
assert_eq!(out[2].driver.as_str(), "psp-b");
|
|
assert_eq!(out[3].driver.as_str(), "psp-d");
|
|
}
|
|
|
|
#[test]
|
|
fn excluded_overrides_preferred_silently() {
|
|
// Pathological config: same driver listed as both. Exclude wins.
|
|
let policy = MccPolicy::new().with_rule(
|
|
Mcc::from_str("7995").expect("test mcc"),
|
|
McuPreferences::default()
|
|
.with_preferred(DriverId::new("psp-a"))
|
|
.with_excluded(DriverId::new("psp-a")),
|
|
);
|
|
let pool = vec![r("psp-a"), r("psp-b")];
|
|
let out = policy.filter(Some(Mcc::from_str("7995").expect("test mcc")), &pool);
|
|
// psp-a was excluded — gone.
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].driver.as_str(), "psp-b");
|
|
}
|
|
|
|
#[test]
|
|
fn none_mcc_passes_through() {
|
|
let policy = MccPolicy::new().with_rule(
|
|
Mcc::from_str("5411").expect("test mcc"),
|
|
McuPreferences::default().with_excluded(DriverId::new("a")),
|
|
);
|
|
let pool = vec![r("a"), r("b")];
|
|
let out = policy.filter(None, &pool);
|
|
assert_eq!(out.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn max_attempts_for_falls_back_to_default() {
|
|
let policy = MccPolicy::new();
|
|
assert_eq!(policy.max_attempts_for(None, 4), 4);
|
|
assert_eq!(
|
|
policy.max_attempts_for(Some(Mcc::from_str("5411").expect("test mcc")), 4),
|
|
4,
|
|
);
|
|
|
|
let policy = MccPolicy::new().with_rule(
|
|
Mcc::from_str("5411").expect("test mcc"),
|
|
McuPreferences::default().with_max_attempts(2),
|
|
);
|
|
assert_eq!(
|
|
policy.max_attempts_for(Some(Mcc::from_str("5411").expect("test mcc")), 4),
|
|
2,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn catalogue_parses_at_runtime() {
|
|
let cat = mcc_catalogue();
|
|
// Should be populated.
|
|
assert!(cat.len() > 150, "expected >150 MCCs, got {}", cat.len());
|
|
// Spot-check well-known codes.
|
|
let codes: HashSet<&str> = cat.iter().map(|c| c.mcc.as_str()).collect();
|
|
assert!(codes.contains("5411")); // grocery
|
|
assert!(codes.contains("5812")); // restaurant
|
|
assert!(codes.contains("4900")); // utility
|
|
assert!(codes.contains("7995")); // gambling
|
|
assert!(codes.contains("5993")); // tobacco
|
|
}
|
|
}
|