From ecf702aba92bec721ff7e08095739f3809c6c525 Mon Sep 17 00:00:00 2001 From: Sai Harsha Vardhan <56996463+sai-harsha-vardhan@users.noreply.github.com> Date: Thu, 16 Oct 2025 12:05:31 +0530 Subject: [PATCH] feat(router): add pre-confirm payments eligibility api (#9774) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- api-reference/v1/openapi_spec_v1.json | 78 +++++++-- api-reference/v2/openapi_spec_v2.json | 63 ++++++- crates/api_models/src/events/payment.rs | 18 ++ crates/api_models/src/payments.rs | 14 +- crates/router/src/core/blocklist/utils.rs | 73 ++++---- crates/router/src/core/payments.rs | 175 +++++++++++++++++++ crates/router/src/routes/app.rs | 4 + crates/router/src/routes/lock_utils.rs | 3 +- crates/router/src/routes/payments.rs | 46 +++++ crates/router/src/services/authentication.rs | 9 + crates/router/src/types/api/payments.rs | 17 +- crates/router_env/src/logger/types.rs | 2 + 12 files changed, 439 insertions(+), 63 deletions(-) diff --git a/api-reference/v1/openapi_spec_v1.json b/api-reference/v1/openapi_spec_v1.json index 58b315e411..c48b122e0d 100644 --- a/api-reference/v1/openapi_spec_v1.json +++ b/api-reference/v1/openapi_spec_v1.json @@ -19542,13 +19542,62 @@ } }, "NextActionCall": { - "type": "string", - "enum": [ - "post_session_tokens", - "confirm", - "sync", - "complete_authorize", - "await_merchant_callback" + "oneOf": [ + { + "type": "string", + "description": "The next action call is Post Session Tokens", + "enum": [ + "post_session_tokens" + ] + }, + { + "type": "string", + "description": "The next action call is confirm", + "enum": [ + "confirm" + ] + }, + { + "type": "string", + "description": "The next action call is sync", + "enum": [ + "sync" + ] + }, + { + "type": "string", + "description": "The next action call is Complete Authorize", + "enum": [ + "complete_authorize" + ] + }, + { + "type": "string", + "description": "The next action is to await for a merchant callback", + "enum": [ + "await_merchant_callback" + ] + }, + { + "type": "object", + "required": [ + "deny" + ], + "properties": { + "deny": { + "type": "object", + "description": "The next action is to deny the payment with an error message", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } ] }, "NextActionData": { @@ -24552,14 +24601,17 @@ }, "PaymentsEligibilityResponse": { "type": "object", + "required": [ + "payment_id", + "sdk_next_action" + ], "properties": { + "payment_id": { + "type": "string", + "description": "The identifier for the payment" + }, "sdk_next_action": { - "allOf": [ - { - "$ref": "#/components/schemas/SdkNextAction" - } - ], - "nullable": true + "$ref": "#/components/schemas/SdkNextAction" } } }, diff --git a/api-reference/v2/openapi_spec_v2.json b/api-reference/v2/openapi_spec_v2.json index d4094e383a..7a6803d508 100644 --- a/api-reference/v2/openapi_spec_v2.json +++ b/api-reference/v2/openapi_spec_v2.json @@ -15547,13 +15547,62 @@ } }, "NextActionCall": { - "type": "string", - "enum": [ - "post_session_tokens", - "confirm", - "sync", - "complete_authorize", - "await_merchant_callback" + "oneOf": [ + { + "type": "string", + "description": "The next action call is Post Session Tokens", + "enum": [ + "post_session_tokens" + ] + }, + { + "type": "string", + "description": "The next action call is confirm", + "enum": [ + "confirm" + ] + }, + { + "type": "string", + "description": "The next action call is sync", + "enum": [ + "sync" + ] + }, + { + "type": "string", + "description": "The next action call is Complete Authorize", + "enum": [ + "complete_authorize" + ] + }, + { + "type": "string", + "description": "The next action is to await for a merchant callback", + "enum": [ + "await_merchant_callback" + ] + }, + { + "type": "object", + "required": [ + "deny" + ], + "properties": { + "deny": { + "type": "object", + "description": "The next action is to deny the payment with an error message", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } ] }, "NextActionData": { diff --git a/crates/api_models/src/events/payment.rs b/crates/api_models/src/events/payment.rs index ad10722517..104fe4e089 100644 --- a/crates/api_models/src/events/payment.rs +++ b/crates/api_models/src/events/payment.rs @@ -174,6 +174,24 @@ impl ApiEventMetric for payments::PaymentsRequest { } } +#[cfg(feature = "v1")] +impl ApiEventMetric for payments::PaymentsEligibilityRequest { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Payment { + payment_id: self.payment_id.clone(), + }) + } +} + +#[cfg(feature = "v1")] +impl ApiEventMetric for payments::PaymentsEligibilityResponse { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Payment { + payment_id: self.payment_id.clone(), + }) + } +} + #[cfg(feature = "v2")] impl ApiEventMetric for PaymentsCreateIntentRequest { fn get_api_event_type(&self) -> Option { diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index 6a51e5c5d7..937cdaf98d 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -8064,6 +8064,8 @@ pub enum NextActionCall { CompleteAuthorize, /// The next action is to await for a merchant callback AwaitMerchantCallback, + /// The next action is to deny the payment with an error message + Deny { message: String }, } #[derive(Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize, ToSchema)] @@ -9334,9 +9336,13 @@ pub struct ClickToPaySessionResponse { #[derive(Debug, serde::Deserialize, Clone, ToSchema)] pub struct PaymentsEligibilityRequest { + /// The identifier for the payment + /// Added in the payload for ApiEventMetrics, populated from the path param + #[serde(skip)] + pub payment_id: id_type::PaymentId, /// Token used for client side verification #[schema(value_type = String, example = "pay_U42c409qyHwOkWo3vK60_secret_el9ksDkiB8hi6j9N78yo")] - pub client_secret: Secret, + pub client_secret: Option>, /// The payment method to be used for the payment #[schema(value_type = PaymentMethod, example = "wallet")] pub payment_method_type: api_enums::PaymentMethod, @@ -9349,7 +9355,11 @@ pub struct PaymentsEligibilityRequest { #[derive(Debug, serde::Serialize, Clone, ToSchema)] pub struct PaymentsEligibilityResponse { - pub sdk_next_action: Option, + /// The identifier for the payment + #[schema(value_type = String)] + pub payment_id: id_type::PaymentId, + /// Next action to be performed by the SDK + pub sdk_next_action: SdkNextAction, } #[cfg(feature = "v1")] diff --git a/crates/router/src/core/blocklist/utils.rs b/crates/router/src/core/blocklist/utils.rs index a00c4b5beb..c849133b72 100644 --- a/crates/router/src/core/blocklist/utils.rs +++ b/crates/router/src/core/blocklist/utils.rs @@ -290,45 +290,40 @@ async fn delete_card_bin_blocklist_entry( }) } -pub async fn validate_data_for_blocklist( +pub async fn should_payment_be_blocked( state: &SessionState, merchant_context: &domain::MerchantContext, - payment_data: &mut PaymentData, -) -> CustomResult -where - F: Send + Clone, -{ + payment_method_data: &Option, +) -> CustomResult { let db = &state.store; let merchant_id = merchant_context.get_merchant_account().get_id(); let merchant_fingerprint_secret = get_merchant_fingerprint_secret(state, merchant_id).await?; // Hashed Fingerprint to check whether or not this payment should be blocked. - let card_number_fingerprint = if let Some(domain::PaymentMethodData::Card(card)) = - payment_data.payment_method_data.as_ref() - { - generate_fingerprint( - state, - StrongSecret::new(card.card_number.get_card_no()), - StrongSecret::new(merchant_fingerprint_secret.clone()), - api_models::enums::LockerChoice::HyperswitchCardVault, - ) - .await - .attach_printable("error in pm fingerprint creation") - .map_or_else( - |error| { - logger::error!(?error); - None - }, - Some, - ) - .map(|payload| payload.card_fingerprint) - } else { - None - }; + let card_number_fingerprint = + if let Some(domain::PaymentMethodData::Card(card)) = payment_method_data { + generate_fingerprint( + state, + StrongSecret::new(card.card_number.get_card_no()), + StrongSecret::new(merchant_fingerprint_secret.clone()), + api_models::enums::LockerChoice::HyperswitchCardVault, + ) + .await + .attach_printable("error in pm fingerprint creation") + .map_or_else( + |error| { + logger::error!(?error); + None + }, + Some, + ) + .map(|payload| payload.card_fingerprint) + } else { + None + }; // Hashed Cardbin to check whether or not this payment should be blocked. - let card_bin_fingerprint = payment_data - .payment_method_data + let card_bin_fingerprint = payment_method_data .as_ref() .and_then(|pm_data| match pm_data { domain::PaymentMethodData::Card(card) => Some(card.card_number.get_card_isin()), @@ -337,8 +332,7 @@ where // Hashed Extended Cardbin to check whether or not this payment should be blocked. let extended_card_bin_fingerprint = - payment_data - .payment_method_data + payment_method_data .as_ref() .and_then(|pm_data| match pm_data { domain::PaymentMethodData::Card(card) => { @@ -385,6 +379,21 @@ where } } } + Ok(should_payment_be_blocked) +} + +pub async fn validate_data_for_blocklist( + state: &SessionState, + merchant_context: &domain::MerchantContext, + payment_data: &mut PaymentData, +) -> CustomResult +where + F: Send + Clone, +{ + let db = &state.store; + let should_payment_be_blocked = + should_payment_be_blocked(state, merchant_context, &payment_data.payment_method_data) + .await?; if should_payment_be_blocked { // Update db for attempt and intent status. db.update_payment_intent( diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index 7c953a5325..903dfa58d6 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -104,6 +104,8 @@ use super::{ }, }; #[cfg(feature = "v1")] +use crate::core::blocklist::utils as blocklist_utils; +#[cfg(feature = "v1")] use crate::core::debit_routing; #[cfg(feature = "frm")] use crate::core::fraud_check as frm_core; @@ -10986,6 +10988,179 @@ pub async fn payments_manual_update( )) } +// Trait for Eligibility Checks +#[cfg(feature = "v1")] +#[async_trait::async_trait] +trait EligibilityCheck { + type Output; + + // Determine if the check should be run based on the runtime checks + async fn should_run( + &self, + state: &SessionState, + merchant_context: &domain::MerchantContext, + ) -> CustomResult; + + // Run the actual check and return the SDK Next Action if applicable + async fn execute_check( + &self, + state: &SessionState, + merchant_context: &domain::MerchantContext, + payment_method_data: &Option, + ) -> CustomResult; + + fn transform(output: Self::Output) -> Option; +} + +// Result of an Eligibility Check +#[cfg(feature = "v1")] +#[derive(Debug, Clone)] +pub enum CheckResult { + Allow, + Deny { message: String }, +} + +#[cfg(feature = "v1")] +impl From for Option { + fn from(result: CheckResult) -> Self { + match result { + CheckResult::Allow => None, + CheckResult::Deny { message } => Some(api_models::payments::SdkNextAction { + next_action: api_models::payments::NextActionCall::Deny { message }, + }), + } + } +} + +// Perform Blocklist Check for the Card Number provided in Payment Method Data +#[cfg(feature = "v1")] +struct BlockListCheck; + +#[cfg(feature = "v1")] +#[async_trait::async_trait] +impl EligibilityCheck for BlockListCheck { + type Output = CheckResult; + + async fn should_run( + &self, + state: &SessionState, + merchant_context: &domain::MerchantContext, + ) -> CustomResult { + let merchant_id = merchant_context.get_merchant_account().get_id(); + let blocklist_enabled_key = merchant_id.get_blocklist_guard_key(); + let blocklist_guard_enabled = state + .store + .find_config_by_key_unwrap_or(&blocklist_enabled_key, Some("false".to_string())) + .await; + + Ok(match blocklist_guard_enabled { + Ok(config) => serde_json::from_str(&config.config).unwrap_or(false), + + // If it is not present in db we are defaulting it to false + Err(inner) => { + if !inner.current_context().is_db_not_found() { + logger::error!("Error fetching guard blocklist enabled config {:?}", inner); + } + false + } + }) + } + + async fn execute_check( + &self, + state: &SessionState, + merchant_context: &domain::MerchantContext, + payment_method_data: &Option, + ) -> CustomResult { + let should_payment_be_blocked = blocklist_utils::should_payment_be_blocked( + state, + merchant_context, + payment_method_data, + ) + .await?; + if should_payment_be_blocked { + Ok(CheckResult::Deny { + message: "Card number is blocklisted".to_string(), + }) + } else { + 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, +} + +#[cfg(feature = "v1")] +impl EligibilityHandler { + fn new( + state: SessionState, + merchant_context: domain::MerchantContext, + payment_method_data: Option, + ) -> Self { + Self { + state, + merchant_context, + payment_method_data, + } + } + + async fn run_check( + &self, + check: C, + ) -> CustomResult, errors::ApiErrorResponse> { + let should_run = check + .should_run(&self.state, &self.merchant_context) + .await?; + Ok(match should_run { + true => check + .execute_check( + &self.state, + &self.merchant_context, + &self.payment_method_data, + ) + .await + .map(C::transform)?, + false => None, + }) + } +} + +#[cfg(all(feature = "oltp", feature = "v1"))] +pub async fn payments_submit_eligibility( + state: SessionState, + merchant_context: domain::MerchantContext, + 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 sdk_next_action = eligibility_handler + .run_check(BlockListCheck) + .await? + .unwrap_or(api_models::payments::SdkNextAction { + next_action: api_models::payments::NextActionCall::Confirm, + }); + Ok(services::ApplicationResponse::Json( + api_models::payments::PaymentsEligibilityResponse { + payment_id, + sdk_next_action, + }, + )) +} + pub trait PaymentMethodChecker { fn should_update_in_post_update_tracker(&self) -> bool; fn should_update_in_update_tracker(&self) -> bool; diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 36a1b02b61..f575f80af1 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -915,6 +915,10 @@ impl Payments { web::resource("/{payment_id}/reject") .route(web::post().to(payments::payments_reject)), ) + .service( + web::resource("/{payment_id}/eligibility") + .route(web::post().to(payments::payments_submit_eligibility)), + ) .service( web::resource("/redirect/{payment_id}/{merchant_id}/{attempt_id}") .route(web::get().to(payments::payments_start)), diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index e7c9f97c00..a8b7294c1c 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -170,7 +170,8 @@ impl From for ApiIdentifier { | Flow::ProxyConfirmIntent | Flow::PaymentsRetrieveUsingMerchantReferenceId | Flow::PaymentAttemptsList - | Flow::RecoveryPaymentsCreate => Self::Payments, + | Flow::RecoveryPaymentsCreate + | Flow::PaymentsSubmitEligibility => Self::Payments, Flow::PayoutsCreate | Flow::PayoutsRetrieve | Flow::PayoutsUpdate diff --git a/crates/router/src/routes/payments.rs b/crates/router/src/routes/payments.rs index 0cbfeff4c9..c790533ec5 100644 --- a/crates/router/src/routes/payments.rs +++ b/crates/router/src/routes/payments.rs @@ -2453,6 +2453,52 @@ pub async fn retrieve_extended_card_info( .await } +#[cfg(all(feature = "oltp", feature = "v1"))] +#[instrument(skip_all, fields(flow = ?Flow::PaymentsSubmitEligibility, payment_id))] +pub async fn payments_submit_eligibility( + state: web::Data, + http_req: actix_web::HttpRequest, + json_payload: web::Json, + path: web::Path, +) -> impl Responder { + let flow = Flow::PaymentsSubmitEligibility; + let payment_id = path.into_inner(); + let mut payload = json_payload.into_inner(); + payload.payment_id = payment_id.clone(); + + let api_auth = auth::ApiKeyAuth { + is_connected_allowed: false, + is_platform_allowed: true, + }; + + let (auth_type, _auth_flow) = + match auth::check_client_secret_and_get_auth(http_req.headers(), &payload, api_auth) { + Ok(auth) => auth, + Err(err) => return api::log_and_return_error_response(report!(err)), + }; + + Box::pin(api::server_wrap( + flow, + state, + &http_req, + payment_id, + |state, auth: auth::AuthenticationData, payment_id, _| { + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(auth.merchant_account, auth.key_store), + )); + payments::payments_submit_eligibility( + state, + merchant_context, + payload.clone(), + payment_id, + ) + }, + &*auth_type, + api_locking::LockAction::NotApplicable, + )) + .await +} + #[cfg(feature = "v1")] pub fn get_or_generate_payment_id( payload: &mut payment_types::PaymentsRequest, diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index 042dac243b..dcdb1ffe6f 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -4261,6 +4261,15 @@ impl ClientSecretFetch for payments::PaymentsRequest { } } +#[cfg(feature = "v1")] +impl ClientSecretFetch for payments::PaymentsEligibilityRequest { + fn get_client_secret(&self) -> Option<&String> { + self.client_secret + .as_ref() + .map(|client_secret| client_secret.peek()) + } +} + #[cfg(feature = "v1")] impl ClientSecretFetch for api_models::blocklist::ListBlocklistQuery { fn get_client_secret(&self) -> Option<&String> { diff --git a/crates/router/src/types/api/payments.rs b/crates/router/src/types/api/payments.rs index 4b3f448de1..bbafc1af3d 100644 --- a/crates/router/src/types/api/payments.rs +++ b/crates/router/src/types/api/payments.rs @@ -23,14 +23,15 @@ pub use api_models::{ PaymentsAggregateResponse, PaymentsApproveRequest, PaymentsCancelPostCaptureRequest, PaymentsCancelRequest, PaymentsCaptureRequest, PaymentsCompleteAuthorizeRequest, PaymentsDynamicTaxCalculationRequest, PaymentsDynamicTaxCalculationResponse, - 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, + 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, }, }; pub use common_types::payments::{AcceptanceType, CustomerAcceptance, OnlineMandate}; diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 81975b2df0..1968447a16 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -678,6 +678,8 @@ pub enum Flow { RevenueRecoveryRedis, /// Gift card balance check flow GiftCardBalanceCheck, + /// Payments Submit Eligibility flow + PaymentsSubmitEligibility, } /// Trait for providing generic behaviour to flow metric