op-3ds2: an unconverted foreign amount is not under an EUR SCA cap #287
1 changed files with 186 additions and 12 deletions
|
|
@ -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<i64>,
|
||||
/// 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<EligibleE
|
|||
}
|
||||
|
||||
// 8. TRA — only if the bracket permits and the score is supplied.
|
||||
// `tra_cap_minor_units` is an EUR figure, so the amount has to
|
||||
// reach EUR before it is compared; an unconverted foreign amount
|
||||
// is not under the cap.
|
||||
if let (Some(score), Some(cap)) = (ctx.tra_score, ctx.fraud_rate_bracket.tra_cap_minor_units())
|
||||
&& intent.amount.minor_units <= cap
|
||||
&& let Some(eur_minor) = amount_in_eur_minor(intent, ctx)
|
||||
&& eur_minor <= cap
|
||||
&& (0.0..=1.0).contains(&score)
|
||||
{
|
||||
out.push(EligibleExemption::TransactionRiskAnalysis {
|
||||
|
|
@ -342,16 +357,48 @@ const fn variant_name(e: &EligibleExemption) -> &'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<i64> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue