diff --git a/api-reference/v1/openapi_spec_v1.json b/api-reference/v1/openapi_spec_v1.json index 6367908a06..0924f9d2fd 100644 --- a/api-reference/v1/openapi_spec_v1.json +++ b/api-reference/v1/openapi_spec_v1.json @@ -24706,6 +24706,14 @@ }, "payment_method_data": { "$ref": "#/components/schemas/PaymentMethodDataRequest" + }, + "browser_info": { + "allOf": [ + { + "$ref": "#/components/schemas/BrowserInformation" + } + ], + "nullable": true } } }, diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index 9f1a57d89d..a8af628aea 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -9352,6 +9352,7 @@ pub struct ClickToPaySessionResponse { pub dpa_client_id: Option, } +#[cfg(feature = "v1")] #[derive(Debug, serde::Deserialize, Clone, ToSchema)] pub struct PaymentsEligibilityRequest { /// The identifier for the payment @@ -9369,6 +9370,9 @@ pub struct PaymentsEligibilityRequest { pub payment_method_subtype: Option, /// The payment instrument data to be used for the payment pub payment_method_data: PaymentMethodDataRequest, + /// The browser information for the payment + #[schema(value_type = Option)] + pub browser_info: Option, } #[derive(Debug, serde::Serialize, Clone, ToSchema)] diff --git a/crates/common_utils/src/ext_traits.rs b/crates/common_utils/src/ext_traits.rs index b60ef5a22f..3205c05168 100644 --- a/crates/common_utils/src/ext_traits.rs +++ b/crates/common_utils/src/ext_traits.rs @@ -339,6 +339,12 @@ pub trait AsyncExt { where F: FnOnce() -> Fut + Send, Fut: futures::Future + Send; + + /// Extending `or_else` to allow async fallback that returns Self::WrappedSelf + async fn async_or_else(self, func: F) -> Self::WrappedSelf + where + F: FnOnce() -> Fut + Send, + Fut: futures::Future> + Send; } #[cfg(feature = "async_ext")] @@ -381,6 +387,21 @@ impl AsyncExt for Result { } } } + + async fn async_or_else(self, func: F) -> Self::WrappedSelf + where + F: FnOnce() -> Fut + Send, + Fut: futures::Future> + Send, + { + match self { + Ok(a) => Ok(a), + Err(_err) => { + #[cfg(feature = "logs")] + logger::error!("Error: {:?}", _err); + func().await + } + } + } } #[cfg(feature = "async_ext")] @@ -419,6 +440,17 @@ impl AsyncExt for Option { None => func().await, } } + + async fn async_or_else(self, func: F) -> Self::WrappedSelf + where + F: FnOnce() -> Fut + Send, + Fut: futures::Future> + Send, + { + match self { + Some(a) => Some(a), + None => func().await, + } + } } /// Extension trait for validating application configuration. This trait provides utilities to diff --git a/crates/router/src/core/card_testing_guard/utils.rs b/crates/router/src/core/card_testing_guard/utils.rs index 85f949b3b8..679babfc9f 100644 --- a/crates/router/src/core/card_testing_guard/utils.rs +++ b/crates/router/src/core/card_testing_guard/utils.rs @@ -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, business_profile: &domain::Profile, ) -> RouterResult> { 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> { 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) diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index 7f80bcf050..1de6384ff6 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -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, + pub payment_intent: storage::PaymentIntent, + pub browser_info: Option, +} + +#[cfg(feature = "v1")] +impl PaymentEligibilityData { + pub async fn from_request( + state: &SessionState, + merchant_context: &domain::MerchantContext, + payments_eligibility_request: &api_models::payments::PaymentsEligibilityRequest, + ) -> CustomResult { + 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, + payment_elgibility_data: &PaymentEligibilityData, + business_profile: &domain::Profile, ) -> CustomResult; fn transform(output: Self::Output) -> Option; @@ -10704,12 +10757,13 @@ impl EligibilityCheck for BlockListCheck { &self, state: &SessionState, merchant_context: &domain::MerchantContext, - payment_method_data: &Option, + payment_elgibility_data: &PaymentEligibilityData, + _business_profile: &domain::Profile, ) -> CustomResult { 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 { + // 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 { + 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 { + 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, + 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, + 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 { - 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, }); diff --git a/crates/router/src/core/payments/operations/payment_confirm.rs b/crates/router/src/core/payments/operations/payment_confirm.rs index 2f4a574d46..c1145aeea5 100644 --- a/crates/router/src/core/payments/operations/payment_confirm.rs +++ b/crates/router/src/core/payments/operations/payment_confirm.rs @@ -863,12 +863,12 @@ impl GetTracker, 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, ) diff --git a/crates/router/src/types/api/payments.rs b/crates/router/src/types/api/payments.rs index bbafc1af3d..3ef8a2b903 100644 --- a/crates/router/src/types/api/payments.rs +++ b/crates/router/src/types/api/payments.rs @@ -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};