diff --git a/openpay/crates/op-3ds2/src/exemption.rs b/openpay/crates/op-3ds2/src/exemption.rs index 7ab1ed04d..b96ca6640 100644 --- a/openpay/crates/op-3ds2/src/exemption.rs +++ b/openpay/crates/op-3ds2/src/exemption.rs @@ -40,7 +40,7 @@ //! reflects this. use chrono::{DateTime, Utc}; -use op_core::Money; +use op_core::{Currency, Money}; use serde::{Deserialize, Serialize}; use crate::auth_response::TransactionStatus; @@ -202,6 +202,17 @@ 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, + /// The intent's amount converted to **EUR minor units**, when the + /// caller holds an FX rate (see `op-fx`). + /// + /// The RTS caps this module enforces — Article 16 low-value and + /// Article 18 TRA — are denominated in EUR, so a non-EUR amount is + /// not comparable to them until it is converted. `None` means no + /// conversion was supplied, which for a non-EUR amount makes those + /// two exemptions ineligible rather than eligible: an unconverted + /// figure is not a figure below the cap. EUR amounts need nothing + /// here. + pub eur_equivalent_minor: Option, /// Count of consecutive low-value-exempt payments without SCA. pub consecutive_low_value_count: u32, /// Requestor's PSP-certified fraud-rate bracket. @@ -295,8 +306,12 @@ pub fn evaluate(intent: &PaymentIntent, ctx: &ExemptionContext) -> Vec &'static str { } } +/// The intent's amount in EUR minor units, or `None` when that is not +/// knowable here. +/// +/// An EUR amount is already in EUR minor units. Any other currency +/// needs [`ExemptionContext::eur_equivalent_minor`]; without it the +/// amount cannot be compared to an EUR-denominated RTS cap. +/// +/// This used to substitute the raw minor-unit figure 1:1 and call that +/// conservative. It is conservative only for currencies worth *less* +/// than EUR per minor unit: GBP 30.00 and CHF 30.00 both satisfied +/// `3000 <= 3000` while exceeding the EUR 30 Article 16 ceiling, so the +/// uncomputed conversion was being treated as a result inside the cap. +/// `Money` carries the currency, so the information needed to refuse +/// was present and discarded. +fn amount_in_eur_minor(intent: &PaymentIntent, ctx: &ExemptionContext) -> Option { + if intent.amount.currency == Currency::EUR { + Some(intent.amount.minor_units) + } else { + ctx.eur_equivalent_minor + } +} + 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 + // RTS Article 16: EUR 30 per transaction, EUR 100 cumulative across + // a rolling 24 h, at most 5 consecutive. All three are EUR figures, + // so an amount that could not be expressed in EUR is not eligible. + let Some(eur_minor) = amount_in_eur_minor(intent, ctx) else { + return false; + }; + const SINGLE_CAP: i64 = 30_00; + const CUMULATIVE_CAP: i64 = 100_00; + const CONSECUTIVE_CAP: u32 = 5; + // saturating: the cumulative total is caller-supplied, and a plain + // `+` overflowing i64 wraps negative in release and so slips under + // the cap, while panicking in debug. Saturation exceeds the cap in + // both profiles. + eur_minor <= SINGLE_CAP + && ctx + .cumulative_low_value_24h_eur_minor + .saturating_add(eur_minor) + <= CUMULATIVE_CAP + && ctx.consecutive_low_value_count < CONSECUTIVE_CAP } const EEA_COUNTRIES: &[&str] = &[ @@ -366,7 +413,6 @@ fn is_eea(country: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use op_core::Currency; fn eur(minor: i64) -> Money { Money::from_minor(minor, Currency::EUR) @@ -545,4 +591,132 @@ mod tests { assert_eq!(FraudRateBracket::UpTo13Bp.tra_cap_minor_units(), Some(100_00)); assert_eq!(FraudRateBracket::AboveTraThreshold.tra_cap_minor_units(), None); } + + // ---- EUR-denominated caps vs non-EUR amounts -------------------- + // + // The RTS Article 16 and Article 18 caps are EUR figures. The + // evaluator used to compare a raw minor-unit count against them + // whatever the currency, calling the 1:1 substitution conservative. + // It is not: GBP and CHF are worth more than EUR per minor unit. + + fn gbp(minor: i64) -> Money { + Money::from_minor(minor, Currency::GBP) + } + + fn is_low_value(ex: &[EligibleExemption]) -> bool { + ex.iter() + .any(|e| matches!(e, EligibleExemption::LowValueTransaction { .. })) + } + + fn is_tra(ex: &[EligibleExemption]) -> bool { + ex.iter() + .any(|e| matches!(e, EligibleExemption::TransactionRiskAnalysis { .. })) + } + + /// GBP 30.00 is about EUR 35, over the Article 16 ceiling, but its + /// minor-unit count is exactly the EUR cap. Without a conversion the + /// evaluator cannot know which, so the exemption is not available. + #[test] + fn an_unconverted_foreign_amount_is_not_low_value() { + let intent = PaymentIntent { + amount: gbp(30_00), + ..base_intent(30_00) + }; + let ex = evaluate(&intent, &base_ctx()); + assert!( + !is_low_value(&ex), + "GBP 30.00 exceeds the EUR 30 cap and no rate was supplied: {ex:?}" + ); + } + + /// With a rate supplied, the converted figure decides it — on both + /// sides of the cap. This is the pair that shows the fix is about + /// knowing the amount, not about refusing foreign currency. + #[test] + fn a_supplied_conversion_decides_the_low_value_cap() { + let intent = PaymentIntent { + amount: gbp(30_00), + ..base_intent(30_00) + }; + + let over = ExemptionContext { + eur_equivalent_minor: Some(35_00), + ..base_ctx() + }; + assert!( + !is_low_value(&evaluate(&intent, &over)), + "EUR 35.00 is over the EUR 30 cap" + ); + + let under = ExemptionContext { + eur_equivalent_minor: Some(25_00), + ..base_ctx() + }; + assert!( + is_low_value(&evaluate(&intent, &under)), + "EUR 25.00 is under the EUR 30 cap" + ); + } + + /// An EUR amount is already in EUR minor units, so it still clears + /// with no conversion supplied. Guards the common path. + #[test] + fn an_eur_amount_needs_no_conversion() { + let ctx = base_ctx(); + assert!(ctx.eur_equivalent_minor.is_none()); + assert!(is_low_value(&evaluate(&base_intent(25_00), &ctx))); + } + + /// The same hole in the TRA branch: `tra_cap_minor_units` is an EUR + /// figure, and GBP 450.00 is about EUR 527 — over the UpTo1Bp cap of + /// EUR 500 while its minor-unit count sits under it. + #[test] + fn an_unconverted_foreign_amount_is_not_under_the_tra_cap() { + let intent = PaymentIntent { + amount: gbp(450_00), + ..base_intent(450_00) + }; + let ctx = ExemptionContext { + fraud_rate_bracket: FraudRateBracket::UpTo1Bp, + tra_score: Some(0.05), + ..Default::default() + }; + assert!( + !is_tra(&evaluate(&intent, &ctx)), + "no rate was supplied, so the EUR 500 cap cannot be applied" + ); + + let converted_over = ExemptionContext { + eur_equivalent_minor: Some(527_00), + ..ctx.clone() + }; + assert!( + !is_tra(&evaluate(&intent, &converted_over)), + "EUR 527 is over the EUR 500 cap" + ); + + let converted_under = ExemptionContext { + eur_equivalent_minor: Some(460_00), + ..ctx + }; + assert!( + is_tra(&evaluate(&intent, &converted_under)), + "EUR 460 is under the EUR 500 cap" + ); + } + + /// The cumulative total is caller-supplied. A plain `+` overflowing + /// i64 wraps negative in release and slips under the cap; saturation + /// exceeds it in both profiles. + #[test] + fn a_cumulative_total_that_would_overflow_does_not_slip_under_the_cap() { + let ctx = ExemptionContext { + cumulative_low_value_24h_eur_minor: i64::MAX, + ..base_ctx() + }; + assert!( + !is_low_value(&evaluate(&base_intent(1_00), &ctx)), + "a 24 h total of i64::MAX is not under EUR 100" + ); + } }