open-standards/openpay/crates/op-3ds2/src/exemption.rs
Transaction Science 3fe109d58f ingest batch 4: 9 crates · table-stakes completers across all three workspaces
OpenPay (3 crates, ~13k LOC, 175 tests)
  op-connect           sub-merchant onboarding + KYB + split-payments + payouts + 1099-K
  op-3ds2              3DS2.3 SCA processor (AReq/ARes/CReq/CRes/RReq/RRes + exemptions)
  op-tax               US/EU/UK/AU/CA/IN tax with Avalara/Stripe/CCH/Vertex/TaxJar adapters

Smart Byte (3 crates, ~7k LOC, 57 tests)
  smart-byte-libp2p    libp2p transport (kad/gossipsub/mdns/autonat/relay/dcutr/identify/ping/req_resp)
  smart-byte-crdt      LWW/GCounter/PnCounter/OrSet/LwwMap/RgaList/2P-Set + HLC + vector clock + Automerge/Yjs interop
  smart-byte-keri-witness  KERI key-rotation cycle + witness layer + watchers + recovery + delegation

EOC (3 crates, ~8k LOC, 108 tests)
  eoc-tools            function-calling across Anthropic/OpenAI/Google/Mistral/Cohere + parallel exec + sandbox
  eoc-multimodal       vision (CLIP/SigLIP/ColPali) + audio (Whisper/MMS) + video sampling + modality router
  eoc-spec-decode      vanilla + SpS-with-temp + lookahead speculative decoding + EAGLE/Medusa stubs

All three workspaces compile cleanly. Forbidden-unsafe, no unwrap outside tests.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 04:00:04 -04:00

548 lines
20 KiB
Rust

//! SCA-exemption evaluator.
//!
//! PSD2 RTS Article 16-20 + the PSD3 / PSR (2026 baseline) update
//! define eight exemptions to Strong Customer Authentication. A
//! transaction that qualifies for an exemption may be authorized
//! *without* an SCA challenge, dramatically improving conversion.
//!
//! ## Exemptions implemented
//!
//! - [`EligibleExemption::LowValueTransaction`] — RTS Article 16:
//! single payment under €30 AND cumulative under €100 over the
//! trailing 24 hours AND at most 5 consecutive low-value
//! transactions without SCA.
//! - [`EligibleExemption::MerchantInitiated`] — RTS Article 13:
//! MIT outside the scope of SCA; mandate reference required.
//! - [`EligibleExemption::Recurring`] — RTS Article 14: subsequent
//! recurring payments at the same merchant, same amount, same
//! mandate.
//! - [`EligibleExemption::TransactionRiskAnalysis`] — RTS Article 18:
//! TRA exemption thresholds keyed off the requestor's certified
//! fraud-rate bracket (€100 / €250 / €500 amount caps).
//! - [`EligibleExemption::SecureCorporatePayment`] — RTS Article 17:
//! corporate "lodge" cards on a closed-loop B2B network.
//! - [`EligibleExemption::TrustedBeneficiary`] — RTS Article 13(2):
//! cardholder-enrolled trusted-beneficiary list maintained by the
//! issuer.
//! - [`EligibleExemption::DelegatedAuthentication`] — PSD3 / RTS 2024
//! extension: SCA performed by a third party (FIDO, wallet) and
//! evidenced inside the AReq.
//! - [`EligibleExemption::OneLeg`] — RTS Recital 95: "one-leg-out"
//! transactions where either the issuer or the acquirer is outside
//! the EEA fall outside the SCA scope.
//!
//! ## Returns
//!
//! [`evaluate`] returns *all* eligible exemptions in priority order so
//! the caller (typically the orchestrator) can pick one per scheme
//! preference. Choosing TRA when an exemption with no fraud-rate
//! constraint is available is wasted risk budget; the priority order
//! reflects this.
use chrono::{DateTime, Utc};
use op_core::Money;
use serde::{Deserialize, Serialize};
use crate::auth_response::TransactionStatus;
use crate::error::{Error, Result};
/// EBA fraud-rate brackets that gate the TRA-exemption amount caps.
///
/// Source: PSD2 RTS Article 19. PSPs measure their fraud rate over a
/// rolling 90-day window per scheme and re-certify quarterly.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum FraudRateBracket {
/// ≤ 1 bp (0.01 %) fraud rate → TRA allowed up to €500.
UpTo1Bp,
/// ≤ 6 bp (0.06 %) → TRA allowed up to €250.
UpTo6Bp,
/// ≤ 13 bp (0.13 %) → TRA allowed up to €100.
UpTo13Bp,
/// Above 13 bp → TRA not allowed. This is the conservative
/// default for newly-created [`ExemptionContext`] values: it
/// disables TRA until the PSP explicitly attests its current
/// fraud-rate bracket.
#[default]
AboveTraThreshold,
}
impl FraudRateBracket {
/// Returns the TRA cap (EUR minor units) the bracket permits.
/// `None` means the bracket is over the regulatory threshold and
/// TRA is unavailable.
#[must_use]
pub const fn tra_cap_minor_units(self) -> Option<i64> {
match self {
Self::UpTo1Bp => Some(500_00),
Self::UpTo6Bp => Some(250_00),
Self::UpTo13Bp => Some(100_00),
Self::AboveTraThreshold => None,
}
}
}
/// Classification of the SCA second factor when delegated.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum SecondFactorClass {
/// Possession + inherence (FIDO authenticator).
Fido,
/// Possession + knowledge (wallet PIN + device).
WalletPin,
/// Inherence + knowledge (biometric + password).
BiometricKnowledge,
}
/// Concrete exemption the evaluator returns as eligible.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum EligibleExemption {
/// Low-value (RTS Article 16).
LowValueTransaction {
/// The amount this exemption was evaluated against.
amount: Money,
},
/// Merchant-initiated transaction outside the SCA scope.
MerchantInitiated {
/// Mandate reference under which the cardholder pre-authorized
/// the future merchant-initiated charge.
mandate_ref: String,
},
/// Recurring (subsequent recurring payment under an existing
/// mandate).
Recurring {
/// Mandate reference.
mandate_ref: String,
/// Subscription / plan id at the merchant.
subscription_id: String,
},
/// Transaction Risk Analysis (RTS Article 18).
TransactionRiskAnalysis {
/// Risk score in [0.0, 1.0]; lower is better.
score: f32,
/// Requestor's currently-certified fraud-rate bracket.
fraud_rate_bracket: FraudRateBracket,
},
/// Secure Corporate Payment (RTS Article 17).
SecureCorporatePayment {
/// True if the card BIN range is a known corporate/lodge BIN.
corporate_card_indicator: bool,
},
/// Trusted Beneficiary, cardholder-enrolled at the issuer.
TrustedBeneficiary {
/// Issuer-side beneficiary id.
beneficiary_id: String,
/// When the cardholder enrolled the merchant as a trusted
/// beneficiary. Issuers may require a cool-off period.
added_at: DateTime<Utc>,
},
/// Delegated Authentication (PSD3 / RTS 2024).
DelegatedAuthentication {
/// Provider name (e.g. `"fido"`, `"applepay"`, `"googlepay"`).
provider: String,
/// Opaque evidence blob carried into the AReq.
evidence: Vec<u8>,
/// Class of the second factor used.
factor_class: SecondFactorClass,
},
/// One-leg-out (issuer or acquirer outside the EEA).
OneLeg {
/// ISO 3166-1 alpha-2 country code of the issuer.
issuer_country: String,
/// ISO 3166-1 alpha-2 country code of the acquirer.
acquirer_country: String,
},
}
impl EligibleExemption {
/// The `threeDSRequestorChallengeInd` value the exemption maps to.
/// Used by the AReq codec to set the field.
#[must_use]
pub const fn challenge_indicator(&self) -> &'static str {
match self {
Self::LowValueTransaction { .. } => "08",
Self::TransactionRiskAnalysis { .. } => "05",
Self::SecureCorporatePayment { .. } => "07",
Self::TrustedBeneficiary { .. } => "09",
Self::DelegatedAuthentication { .. } => "06",
Self::MerchantInitiated { .. } | Self::Recurring { .. } | Self::OneLeg { .. } => "02",
}
}
/// `transStatus` letter this exemption produces if accepted.
#[must_use]
pub const fn implied_status(&self) -> TransactionStatus {
match self {
Self::OneLeg { .. } => TransactionStatus::InfoOnly,
_ => TransactionStatus::AttemptedSuccessful,
}
}
}
/// Lightweight stand-in for the orchestrator's PaymentIntent so the
/// evaluator stays free of an `op-orchestrator` dependency (which
/// would otherwise be circular).
#[derive(Debug, Clone)]
pub struct PaymentIntent {
/// Amount to be charged.
pub amount: Money,
/// PAN being charged (used only for corporate-BIN heuristic).
pub pan: String,
/// Cardholder-supplied indication that this is recurring.
pub recurring: bool,
/// Merchant-initiated indicator.
pub merchant_initiated: bool,
/// Mandate reference, if a recurring/MIT mandate exists.
pub mandate_ref: Option<String>,
/// Subscription id, if recurring.
pub subscription_id: Option<String>,
}
/// Runtime context the evaluator combines with the intent.
#[derive(Debug, Clone, Default)]
pub struct ExemptionContext {
/// Cumulative low-value charges in the trailing 24 h, in EUR
/// minor units, for this cardholder.
pub cumulative_low_value_24h_eur_minor: i64,
/// Count of consecutive low-value-exempt payments without SCA.
pub consecutive_low_value_count: u32,
/// Requestor's PSP-certified fraud-rate bracket.
pub fraud_rate_bracket: FraudRateBracket,
/// Optional TRA risk score for this transaction.
pub tra_score: Option<f32>,
/// True if the BIN range is corporate / lodge per scheme tables.
pub corporate_card_indicator: bool,
/// Trusted-beneficiary record on file with the issuer, if any.
pub trusted_beneficiary_id: Option<String>,
/// Trusted-beneficiary enrolment timestamp.
pub trusted_beneficiary_added_at: Option<DateTime<Utc>>,
/// Delegated-authentication evidence and provider, if any.
pub delegated_auth: Option<(String, Vec<u8>, SecondFactorClass)>,
/// Issuer country (ISO 3166-1 alpha-2).
pub issuer_country: Option<String>,
/// Acquirer country (ISO 3166-1 alpha-2).
pub acquirer_country: Option<String>,
}
/// Evaluate all eligible SCA exemptions for the intent + context, in
/// preference order (cheapest, least-risk-budget first).
///
/// The orchestrator typically picks the first element of the returned
/// vec. Callers that prefer a specific exemption can scan the vec.
#[must_use]
pub fn evaluate(intent: &PaymentIntent, ctx: &ExemptionContext) -> Vec<EligibleExemption> {
let mut out = Vec::new();
// 1. One-leg-out: cheapest of all, no fraud-rate constraint,
// no liability exposure to the issuer. Triggers when either
// leg sits outside the EEA.
if let (Some(iss), Some(acq)) = (&ctx.issuer_country, &ctx.acquirer_country)
&& (!is_eea(iss) || !is_eea(acq))
{
out.push(EligibleExemption::OneLeg {
issuer_country: iss.clone(),
acquirer_country: acq.clone(),
});
}
// 2. Trusted Beneficiary — high success, no challenge.
if let (Some(id), Some(at)) = (
ctx.trusted_beneficiary_id.as_ref(),
ctx.trusted_beneficiary_added_at,
) {
out.push(EligibleExemption::TrustedBeneficiary {
beneficiary_id: id.clone(),
added_at: at,
});
}
// 3. Recurring (subsequent under mandate).
if intent.recurring
&& let (Some(m), Some(sub)) = (intent.mandate_ref.clone(), intent.subscription_id.clone())
{
out.push(EligibleExemption::Recurring {
mandate_ref: m,
subscription_id: sub,
});
}
// 4. Merchant-initiated.
if intent.merchant_initiated
&& let Some(m) = intent.mandate_ref.clone()
{
out.push(EligibleExemption::MerchantInitiated { mandate_ref: m });
}
// 5. Delegated Authentication.
if let Some((provider, evidence, factor_class)) = &ctx.delegated_auth {
out.push(EligibleExemption::DelegatedAuthentication {
provider: provider.clone(),
evidence: evidence.clone(),
factor_class: *factor_class,
});
}
// 6. Secure Corporate Payment.
if ctx.corporate_card_indicator {
out.push(EligibleExemption::SecureCorporatePayment {
corporate_card_indicator: true,
});
}
// 7. Low-value.
if low_value_eligible(intent, ctx) {
out.push(EligibleExemption::LowValueTransaction {
amount: intent.amount,
});
}
// 8. TRA — only if the bracket permits and the score is supplied.
if let (Some(score), Some(cap)) = (ctx.tra_score, ctx.fraud_rate_bracket.tra_cap_minor_units())
&& intent.amount.minor_units <= cap
&& (0.0..=1.0).contains(&score)
{
out.push(EligibleExemption::TransactionRiskAnalysis {
score,
fraud_rate_bracket: ctx.fraud_rate_bracket,
});
}
out
}
/// Construct an exemption explicitly, returning an error if the
/// supplied context does not actually satisfy the regulatory rules.
/// Used by callers that want to enforce a specific exemption and fail
/// loudly when the runtime context isn't sufficient.
pub fn ensure(
requested: &EligibleExemption,
intent: &PaymentIntent,
ctx: &ExemptionContext,
) -> Result<()> {
let eligible = evaluate(intent, ctx);
if eligible.iter().any(|e| matches_variant(e, requested)) {
Ok(())
} else {
Err(Error::ExemptionIneligible(variant_name(requested)))
}
}
fn matches_variant(a: &EligibleExemption, b: &EligibleExemption) -> bool {
core::mem::discriminant(a) == core::mem::discriminant(b)
}
const fn variant_name(e: &EligibleExemption) -> &'static str {
match e {
EligibleExemption::LowValueTransaction { .. } => "LowValueTransaction",
EligibleExemption::MerchantInitiated { .. } => "MerchantInitiated",
EligibleExemption::Recurring { .. } => "Recurring",
EligibleExemption::TransactionRiskAnalysis { .. } => "TransactionRiskAnalysis",
EligibleExemption::SecureCorporatePayment { .. } => "SecureCorporatePayment",
EligibleExemption::TrustedBeneficiary { .. } => "TrustedBeneficiary",
EligibleExemption::DelegatedAuthentication { .. } => "DelegatedAuthentication",
EligibleExemption::OneLeg { .. } => "OneLeg",
}
}
fn low_value_eligible(intent: &PaymentIntent, ctx: &ExemptionContext) -> bool {
// RTS 16 thresholds. We treat all non-EUR currencies conservatively
// by checking the minor-unit value against EUR limits assuming a
// 1:1 EUR equivalency; production deployments convert via op-fx.
let single_cap = 30_00;
let cumulative_cap = 100_00;
let consecutive_cap = 5;
intent.amount.minor_units <= single_cap
&& ctx.cumulative_low_value_24h_eur_minor + intent.amount.minor_units <= cumulative_cap
&& ctx.consecutive_low_value_count < consecutive_cap
}
const EEA_COUNTRIES: &[&str] = &[
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IS", "IE", "IT",
"LV", "LI", "LT", "LU", "MT", "NL", "NO", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
];
fn is_eea(country: &str) -> bool {
EEA_COUNTRIES.iter().any(|c| c.eq_ignore_ascii_case(country))
}
#[cfg(test)]
mod tests {
use super::*;
use op_core::Currency;
fn eur(minor: i64) -> Money {
Money::from_minor(minor, Currency::EUR)
}
fn base_intent(amount: i64) -> PaymentIntent {
PaymentIntent {
amount: eur(amount),
pan: "4111111111111111".into(),
recurring: false,
merchant_initiated: false,
mandate_ref: None,
subscription_id: None,
}
}
fn base_ctx() -> ExemptionContext {
ExemptionContext {
fraud_rate_bracket: FraudRateBracket::AboveTraThreshold,
..Default::default()
}
}
#[test]
fn low_value_25_eur_exempt() {
let intent = base_intent(25_00);
let ex = evaluate(&intent, &base_ctx());
assert!(
ex.iter()
.any(|e| matches!(e, EligibleExemption::LowValueTransaction { .. })),
"expected low-value exemption for 25 EUR, got: {ex:?}"
);
}
#[test]
fn low_value_100_eur_not_exempt_under_low_value() {
let intent = base_intent(100_00);
let ex = evaluate(&intent, &base_ctx());
assert!(
!ex.iter()
.any(|e| matches!(e, EligibleExemption::LowValueTransaction { .. })),
"100 EUR must not qualify as low-value"
);
}
#[test]
fn tra_eligible_when_bracket_and_score_supplied() {
let intent = base_intent(80_00);
let ctx = ExemptionContext {
fraud_rate_bracket: FraudRateBracket::UpTo13Bp,
tra_score: Some(0.05),
..Default::default()
};
let ex = evaluate(&intent, &ctx);
assert!(
ex.iter()
.any(|e| matches!(e, EligibleExemption::TransactionRiskAnalysis { .. })),
"expected TRA exemption to be eligible, got: {ex:?}"
);
}
#[test]
fn tra_capped_by_bracket() {
let intent = base_intent(400_00); // 400 EUR
let ctx = ExemptionContext {
fraud_rate_bracket: FraudRateBracket::UpTo13Bp, // cap 100 EUR
tra_score: Some(0.05),
..Default::default()
};
let ex = evaluate(&intent, &ctx);
assert!(
!ex.iter()
.any(|e| matches!(e, EligibleExemption::TransactionRiskAnalysis { .. })),
"400 EUR exceeds UpTo13Bp cap of 100 EUR"
);
// Same intent under UpTo1Bp (500 EUR cap) qualifies.
let ctx2 = ExemptionContext {
fraud_rate_bracket: FraudRateBracket::UpTo1Bp,
tra_score: Some(0.05),
..Default::default()
};
let ex2 = evaluate(&intent, &ctx2);
assert!(
ex2.iter()
.any(|e| matches!(e, EligibleExemption::TransactionRiskAnalysis { .. })),
"400 EUR within UpTo1Bp 500 EUR cap"
);
}
#[test]
fn recurring_returns_recurring_exemption() {
let intent = PaymentIntent {
recurring: true,
mandate_ref: Some("MNDT-1".into()),
subscription_id: Some("SUB-1".into()),
..base_intent(15_00)
};
let ex = evaluate(&intent, &base_ctx());
assert!(
ex.iter()
.any(|e| matches!(e, EligibleExemption::Recurring { .. }))
);
}
#[test]
fn one_leg_exempt_when_issuer_outside_eea() {
let intent = base_intent(200_00);
let ctx = ExemptionContext {
issuer_country: Some("US".into()),
acquirer_country: Some("DE".into()),
..Default::default()
};
let ex = evaluate(&intent, &ctx);
assert!(
ex.iter()
.any(|e| matches!(e, EligibleExemption::OneLeg { .. })),
"US issuer + DE acquirer must be one-leg-out"
);
}
#[test]
fn trusted_beneficiary_exemption() {
let intent = base_intent(120_00);
let ctx = ExemptionContext {
trusted_beneficiary_id: Some("MERCH-42".into()),
trusted_beneficiary_added_at: Some(Utc::now()),
..Default::default()
};
let ex = evaluate(&intent, &ctx);
assert!(
ex.iter()
.any(|e| matches!(e, EligibleExemption::TrustedBeneficiary { .. }))
);
}
#[test]
fn delegated_auth_exemption() {
let intent = base_intent(500_00);
let ctx = ExemptionContext {
delegated_auth: Some((
"fido".into(),
vec![0x01, 0x02, 0x03],
SecondFactorClass::Fido,
)),
..Default::default()
};
let ex = evaluate(&intent, &ctx);
assert!(
ex.iter()
.any(|e| matches!(e, EligibleExemption::DelegatedAuthentication { .. }))
);
}
#[test]
fn ensure_passes_for_eligible_and_fails_otherwise() {
let intent = base_intent(25_00);
let ctx = base_ctx();
let req = EligibleExemption::LowValueTransaction { amount: eur(25_00) };
assert!(ensure(&req, &intent, &ctx).is_ok());
let bad = EligibleExemption::TransactionRiskAnalysis {
score: 0.1,
fraud_rate_bracket: FraudRateBracket::AboveTraThreshold,
};
assert!(matches!(
ensure(&bad, &intent, &ctx),
Err(Error::ExemptionIneligible(_))
));
}
#[test]
fn fraud_rate_caps_align_with_eba_table() {
assert_eq!(FraudRateBracket::UpTo1Bp.tra_cap_minor_units(), Some(500_00));
assert_eq!(FraudRateBracket::UpTo6Bp.tra_cap_minor_units(), Some(250_00));
assert_eq!(FraudRateBracket::UpTo13Bp.tra_cap_minor_units(), Some(100_00));
assert_eq!(FraudRateBracket::AboveTraThreshold.tra_cap_minor_units(), None);
}
}