feat(router): add card testing check in payments eligibility flow (#9876)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Sai Harsha Vardhan
2025-10-17 13:56:41 +05:30
committed by GitHub
parent 247bce518f
commit 01cb658696
7 changed files with 243 additions and 53 deletions

View File

@ -10,20 +10,21 @@ use crate::{
core::{errors::RouterResult, payments::helpers},
routes::SessionState,
services,
types::{api, domain},
types::domain,
utils::crypto::{self, SignMessage},
};
pub async fn validate_card_testing_guard_checks(
state: &SessionState,
request: &api::PaymentsRequest,
payment_method_data: Option<&api_models::payments::PaymentMethodData>,
#[cfg(feature = "v1")] browser_info: Option<&serde_json::Value>,
#[cfg(feature = "v2")] browser_info: Option<&BrowserInformation>,
card_number: cards::CardNumber,
customer_id: &Option<common_utils::id_type::CustomerId>,
business_profile: &domain::Profile,
) -> RouterResult<Option<CardTestingGuardData>> {
match &business_profile.card_testing_guard_config {
Some(card_testing_guard_config) => {
let fingerprint = generate_fingerprint(payment_method_data, business_profile).await?;
let fingerprint = generate_fingerprint(card_number, business_profile).await?;
let card_testing_guard_expiry = card_testing_guard_config.card_testing_guard_expiry;
@ -32,7 +33,7 @@ pub async fn validate_card_testing_guard_checks(
let mut customer_id_blocking_cache_key = String::new();
if card_testing_guard_config.is_card_ip_blocking_enabled {
if let Some(browser_info) = &request.browser_info {
if let Some(browser_info) = browser_info {
#[cfg(feature = "v1")]
{
let browser_info =
@ -109,34 +110,27 @@ pub async fn validate_card_testing_guard_checks(
}
pub async fn generate_fingerprint(
payment_method_data: Option<&api_models::payments::PaymentMethodData>,
card_number: cards::CardNumber,
business_profile: &domain::Profile,
) -> RouterResult<Secret<String>> {
let card_testing_secret_key = &business_profile.card_testing_secret_key;
match card_testing_secret_key {
Some(card_testing_secret_key) => {
let card_number_fingerprint = payment_method_data
.as_ref()
.and_then(|pm_data| match pm_data {
api_models::payments::PaymentMethodData::Card(card) => {
crypto::HmacSha512::sign_message(
&crypto::HmacSha512,
card_testing_secret_key.get_inner().peek().as_bytes(),
card.card_number.clone().get_card_no().as_bytes(),
)
.attach_printable("error in pm fingerprint creation")
.map_or_else(
|err| {
logger::error!(error=?err);
None
},
Some,
)
}
_ => None,
})
.map(hex::encode);
let card_number_fingerprint = crypto::HmacSha512::sign_message(
&crypto::HmacSha512,
card_testing_secret_key.get_inner().peek().as_bytes(),
card_number.clone().get_card_no().as_bytes(),
)
.attach_printable("error in pm fingerprint creation")
.map_or_else(
|err| {
logger::error!(error=?err);
None
},
Some,
)
.map(hex::encode);
card_number_fingerprint.map(Secret::new).ok_or_else(|| {
error_stack::report!(errors::ApiErrorResponse::InternalServerError)

View File

@ -101,6 +101,8 @@ use super::{
#[cfg(feature = "v1")]
use crate::core::blocklist::utils as blocklist_utils;
#[cfg(feature = "v1")]
use crate::core::card_testing_guard::utils as card_testing_guard_utils;
#[cfg(feature = "v1")]
use crate::core::debit_routing;
#[cfg(feature = "frm")]
use crate::core::fraud_check as frm_core;
@ -7629,6 +7631,56 @@ where
pub is_l2_l3_enabled: bool,
}
#[cfg(feature = "v1")]
#[derive(Clone)]
pub struct PaymentEligibilityData {
pub payment_method_data: Option<domain::PaymentMethodData>,
pub payment_intent: storage::PaymentIntent,
pub browser_info: Option<pii::SecretSerdeValue>,
}
#[cfg(feature = "v1")]
impl PaymentEligibilityData {
pub async fn from_request(
state: &SessionState,
merchant_context: &domain::MerchantContext,
payments_eligibility_request: &api_models::payments::PaymentsEligibilityRequest,
) -> CustomResult<Self, errors::ApiErrorResponse> {
let key_manager_state = &(state).into();
let payment_method_data = payments_eligibility_request
.payment_method_data
.payment_method_data
.clone()
.map(domain::PaymentMethodData::from);
let browser_info = payments_eligibility_request
.browser_info
.clone()
.map(|browser_info| {
serde_json::to_value(browser_info)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Unable to encode payout method data")
})
.transpose()?
.map(pii::SecretSerdeValue::new);
let payment_intent = state
.store
.find_payment_intent_by_payment_id_merchant_id(
key_manager_state,
&payments_eligibility_request.payment_id,
merchant_context.get_merchant_account().get_id(),
merchant_context.get_merchant_key_store(),
merchant_context.get_merchant_account().storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
Ok(Self {
payment_method_data,
browser_info,
payment_intent,
})
}
}
#[derive(Clone, serde::Serialize, Debug)]
pub struct TaxData {
pub shipping_details: hyperswitch_domain_models::address::Address,
@ -10640,7 +10692,8 @@ trait EligibilityCheck {
&self,
state: &SessionState,
merchant_context: &domain::MerchantContext,
payment_method_data: &Option<domain::PaymentMethodData>,
payment_elgibility_data: &PaymentEligibilityData,
business_profile: &domain::Profile,
) -> CustomResult<Self::Output, errors::ApiErrorResponse>;
fn transform(output: Self::Output) -> Option<api_models::payments::SdkNextAction>;
@ -10704,12 +10757,13 @@ impl EligibilityCheck for BlockListCheck {
&self,
state: &SessionState,
merchant_context: &domain::MerchantContext,
payment_method_data: &Option<domain::PaymentMethodData>,
payment_elgibility_data: &PaymentEligibilityData,
_business_profile: &domain::Profile,
) -> CustomResult<CheckResult, errors::ApiErrorResponse> {
let should_payment_be_blocked = blocklist_utils::should_payment_be_blocked(
state,
merchant_context,
payment_method_data,
&payment_elgibility_data.payment_method_data,
)
.await?;
if should_payment_be_blocked {
@ -10726,12 +10780,77 @@ impl EligibilityCheck for BlockListCheck {
}
}
// Perform Card Testing Gaurd Check
#[cfg(feature = "v1")]
struct CardTestingCheck;
#[cfg(feature = "v1")]
#[async_trait::async_trait]
impl EligibilityCheck for CardTestingCheck {
type Output = CheckResult;
async fn should_run(
&self,
_state: &SessionState,
_merchant_context: &domain::MerchantContext,
) -> CustomResult<bool, errors::ApiErrorResponse> {
// This check is always run as there is no runtime config enablement
Ok(true)
}
async fn execute_check(
&self,
state: &SessionState,
_merchant_context: &domain::MerchantContext,
payment_elgibility_data: &PaymentEligibilityData,
business_profile: &domain::Profile,
) -> CustomResult<CheckResult, errors::ApiErrorResponse> {
match &payment_elgibility_data.payment_method_data {
Some(domain::PaymentMethodData::Card(card)) => {
match card_testing_guard_utils::validate_card_testing_guard_checks(
state,
payment_elgibility_data
.browser_info
.as_ref()
.map(|browser_info| browser_info.peek()),
card.card_number.clone(),
&payment_elgibility_data.payment_intent.customer_id,
business_profile,
)
.await
{
// If validation succeeds, allow the payment
Ok(_) => Ok(CheckResult::Allow),
// If validation fails, check the error type
Err(e) => match e.current_context() {
// If it's a PreconditionFailed error, deny with message
errors::ApiErrorResponse::PreconditionFailed { message } => {
Ok(CheckResult::Deny {
message: message.to_string(),
})
}
// For any other error, propagate it
_ => Err(e),
},
}
}
// If payment method is not card, allow
_ => Ok(CheckResult::Allow),
}
}
fn transform(output: CheckResult) -> Option<api_models::payments::SdkNextAction> {
output.into()
}
}
// Eligibility Pipeline to run all the eligibility checks in sequence
#[cfg(feature = "v1")]
pub struct EligibilityHandler {
state: SessionState,
merchant_context: domain::MerchantContext,
payment_method_data: Option<domain::PaymentMethodData>,
payment_eligibility_data: PaymentEligibilityData,
business_profile: domain::Profile,
}
#[cfg(feature = "v1")]
@ -10739,12 +10858,14 @@ impl EligibilityHandler {
fn new(
state: SessionState,
merchant_context: domain::MerchantContext,
payment_method_data: Option<domain::PaymentMethodData>,
payment_eligibility_data: PaymentEligibilityData,
business_profile: domain::Profile,
) -> Self {
Self {
state,
merchant_context,
payment_method_data,
payment_eligibility_data,
business_profile,
}
}
@ -10760,7 +10881,8 @@ impl EligibilityHandler {
.execute_check(
&self.state,
&self.merchant_context,
&self.payment_method_data,
&self.payment_eligibility_data,
&self.business_profile,
)
.await
.map(C::transform)?,
@ -10776,14 +10898,45 @@ pub async fn payments_submit_eligibility(
req: api_models::payments::PaymentsEligibilityRequest,
payment_id: id_type::PaymentId,
) -> RouterResponse<api_models::payments::PaymentsEligibilityResponse> {
let payment_method_data = req
.payment_method_data
.payment_method_data
.map(domain::PaymentMethodData::from);
let eligibility_handler = EligibilityHandler::new(state, merchant_context, payment_method_data);
let key_manager_state = &(&state).into();
let payment_eligibility_data =
PaymentEligibilityData::from_request(&state, &merchant_context, &req).await?;
let profile_id = payment_eligibility_data
.payment_intent
.profile_id
.clone()
.ok_or(errors::ApiErrorResponse::InternalServerError)
.attach_printable("'profile_id' not set in payment intent")?;
let business_profile = state
.store
.find_business_profile_by_profile_id(
key_manager_state,
merchant_context.get_merchant_key_store(),
&profile_id,
)
.await
.to_not_found_response(errors::ApiErrorResponse::ProfileNotFound {
id: profile_id.get_string_repr().to_owned(),
})?;
let eligibility_handler = EligibilityHandler::new(
state,
merchant_context,
payment_eligibility_data,
business_profile,
);
// Run the checks in sequence, short-circuiting on the first that returns a next action
let sdk_next_action = eligibility_handler
.run_check(BlockListCheck)
.await?
.await
.transpose()
.async_or_else(|| async {
eligibility_handler
.run_check(CardTestingCheck)
.await
.transpose()
})
.await
.transpose()?
.unwrap_or(api_models::payments::SdkNextAction {
next_action: api_models::payments::NextActionCall::Confirm,
});

View File

@ -863,12 +863,12 @@ impl<F: Send + Clone + Sync> GetTracker<F, PaymentData<F>, api::PaymentsRequest>
let customer_id = &payment_data.payment_intent.customer_id;
match payment_method_data {
Some(api_models::payments::PaymentMethodData::Card(_card)) => {
Some(api_models::payments::PaymentMethodData::Card(card)) => {
payment_data.card_testing_guard_data =
card_testing_guard_utils::validate_card_testing_guard_checks(
state,
request,
payment_method_data,
request.browser_info.as_ref(),
card.card_number.clone(),
customer_id,
business_profile,
)

View File

@ -7,7 +7,7 @@ pub use api_models::payments::{
#[cfg(feature = "v1")]
pub use api_models::payments::{
PaymentListFilterConstraints, PaymentListResponse, PaymentListResponseV2, PaymentRetrieveBody,
PaymentRetrieveBodyWithCredentials,
PaymentRetrieveBodyWithCredentials, PaymentsEligibilityRequest,
};
pub use api_models::{
feature_matrix::{
@ -23,15 +23,14 @@ pub use api_models::{
PaymentsAggregateResponse, PaymentsApproveRequest, PaymentsCancelPostCaptureRequest,
PaymentsCancelRequest, PaymentsCaptureRequest, PaymentsCompleteAuthorizeRequest,
PaymentsDynamicTaxCalculationRequest, PaymentsDynamicTaxCalculationResponse,
PaymentsEligibilityRequest, PaymentsExternalAuthenticationRequest,
PaymentsIncrementalAuthorizationRequest, PaymentsManualUpdateRequest,
PaymentsPostSessionTokensRequest, PaymentsPostSessionTokensResponse,
PaymentsRedirectRequest, PaymentsRedirectionResponse, PaymentsRejectRequest,
PaymentsRequest, PaymentsResponse, PaymentsResponseForm, PaymentsRetrieveRequest,
PaymentsSessionRequest, PaymentsSessionResponse, PaymentsStartRequest,
PaymentsUpdateMetadataRequest, PaymentsUpdateMetadataResponse, PgRedirectResponse,
PhoneDetails, RedirectionResponse, SessionToken, UrlDetails, VaultSessionDetails,
VerifyRequest, VerifyResponse, VgsSessionDetails, WalletData,
PaymentsExternalAuthenticationRequest, PaymentsIncrementalAuthorizationRequest,
PaymentsManualUpdateRequest, PaymentsPostSessionTokensRequest,
PaymentsPostSessionTokensResponse, PaymentsRedirectRequest, PaymentsRedirectionResponse,
PaymentsRejectRequest, PaymentsRequest, PaymentsResponse, PaymentsResponseForm,
PaymentsRetrieveRequest, PaymentsSessionRequest, PaymentsSessionResponse,
PaymentsStartRequest, PaymentsUpdateMetadataRequest, PaymentsUpdateMetadataResponse,
PgRedirectResponse, PhoneDetails, RedirectionResponse, SessionToken, UrlDetails,
VaultSessionDetails, VerifyRequest, VerifyResponse, VgsSessionDetails, WalletData,
},
};
pub use common_types::payments::{AcceptanceType, CustomerAcceptance, OnlineMandate};