From c0e31d38ffabaef6b459a7513089917d294aacf9 Mon Sep 17 00:00:00 2001 From: Sagnik Mitra <83326850+ImSagnik007@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:28:36 +0530 Subject: [PATCH] feat(core): [Retry] MIT Retries (#8628) --- .../hyperswitch_domain_models/src/mandates.rs | 16 + crates/router/src/core/debit_routing.rs | 9 +- crates/router/src/core/payments.rs | 404 +++++++++++++----- crates/router/src/core/payments/retry.rs | 27 +- crates/router/src/types/api.rs | 1 + crates/router/src/types/api/fraud_check.rs | 8 +- 6 files changed, 341 insertions(+), 124 deletions(-) diff --git a/crates/hyperswitch_domain_models/src/mandates.rs b/crates/hyperswitch_domain_models/src/mandates.rs index 264ed0c6d8..b80e50b5b4 100644 --- a/crates/hyperswitch_domain_models/src/mandates.rs +++ b/crates/hyperswitch_domain_models/src/mandates.rs @@ -14,6 +14,8 @@ use common_utils::{ use error_stack::ResultExt; use time::PrimitiveDateTime; +use crate::router_data::RecurringMandatePaymentData; + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub struct MandateDetails { @@ -174,6 +176,20 @@ pub struct PaymentsMandateReferenceRecord { pub connector_mandate_request_reference_id: Option, } +#[cfg(feature = "v1")] +impl From<&PaymentsMandateReferenceRecord> for RecurringMandatePaymentData { + fn from(mandate_reference_record: &PaymentsMandateReferenceRecord) -> Self { + Self { + payment_method_type: mandate_reference_record.payment_method_type, + original_payment_authorized_amount: mandate_reference_record + .original_payment_authorized_amount, + original_payment_authorized_currency: mandate_reference_record + .original_payment_authorized_currency, + mandate_metadata: mandate_reference_record.mandate_metadata.clone(), + } + } +} + #[cfg(feature = "v2")] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ConnectorTokenReferenceRecord { diff --git a/crates/router/src/core/debit_routing.rs b/crates/router/src/core/debit_routing.rs index 34eaed4290..a09fc2ab1f 100644 --- a/crates/router/src/core/debit_routing.rs +++ b/crates/router/src/core/debit_routing.rs @@ -693,7 +693,7 @@ fn process_connector_for_networks( let matching_networks = find_matching_networks( &merchant_debit_networks, fee_sorted_debit_networks, - &connector_data.connector_data, + connector_data, debit_routing_config, has_us_local_network, ); @@ -715,13 +715,13 @@ fn find_merchant_connector_account( fn find_matching_networks( merchant_debit_networks: &HashSet, fee_sorted_debit_networks: &[common_enums::CardNetwork], - connector_data: &api::ConnectorData, + connector_routing_data: &api::ConnectorRoutingData, debit_routing_config: &settings::DebitRoutingConfig, has_us_local_network: &mut bool, ) -> Vec { let is_routing_enabled = debit_routing_config .supported_connectors - .contains(&connector_data.connector_name); + .contains(&connector_routing_data.connector_data.connector_name.clone()); fee_sorted_debit_networks .iter() @@ -733,8 +733,9 @@ fn find_matching_networks( } api::ConnectorRoutingData { - connector_data: connector_data.clone(), + connector_data: connector_routing_data.connector_data.clone(), network: Some(network.clone()), + action_type: connector_routing_data.action_type.clone(), } }) .collect() diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index 7db83c383d..4f395f3f87 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -8981,88 +8981,64 @@ where .get_required_value("payment_method_info")? .clone(); - //fetch connectors that support ntid flow - let ntid_supported_connectors = &state - .conf - .network_transaction_id_supported_connectors - .connector_list; - //filered connectors list with ntid_supported_connectors - let filtered_ntid_supported_connectors = - filter_ntid_supported_connectors(connectors.clone(), ntid_supported_connectors); + let retryable_connectors = + join_all(connectors.into_iter().map(|connector_routing_data| { + let payment_method = payment_method_info.clone(); + async move { + let action_types = get_all_action_types( + state, + is_connector_agnostic_mit_enabled, + is_network_tokenization_enabled, + &payment_method.clone(), + connector_routing_data.connector_data.clone(), + ) + .await; - //fetch connectors that support network tokenization flow - let network_tokenization_supported_connectors = &state - .conf - .network_tokenization_supported_connectors - .connector_list; - //filered connectors list with ntid_supported_connectors and network_tokenization_supported_connectors - let filtered_nt_supported_connectors = filter_network_tokenization_supported_connectors( - filtered_ntid_supported_connectors, - network_tokenization_supported_connectors, + action_types + .into_iter() + .map(|action_type| api::ConnectorRoutingData { + connector_data: connector_routing_data.connector_data.clone(), + action_type: Some(action_type), + network: connector_routing_data.network.clone(), + }) + .collect::>() + } + })) + .await + .into_iter() + .flatten() + .collect::>(); + + let chosen_connector_routing_data = retryable_connectors + .first() + .ok_or(errors::ApiErrorResponse::IncorrectPaymentMethodConfiguration) + .attach_printable("no eligible connector found for token-based MIT payment")?; + + let mandate_reference_id = get_mandate_reference_id( + chosen_connector_routing_data.action_type.clone(), + chosen_connector_routing_data.clone(), + payment_data, + &payment_method_info, + )?; + + routing_data.routed_through = Some( + chosen_connector_routing_data + .connector_data + .connector_name + .to_string(), ); - let action_type = decide_action_type( - state, - is_connector_agnostic_mit_enabled, - is_network_tokenization_enabled, - &payment_method_info, - filtered_nt_supported_connectors.clone(), - ) - .await; + routing_data.merchant_connector_id.clone_from( + &chosen_connector_routing_data + .connector_data + .merchant_connector_id, + ); - match action_type { - Some(ActionType::NetworkTokenWithNetworkTransactionId(nt_data)) => { - logger::info!( - "using network_tokenization with network_transaction_id for MIT flow" - ); - - let mandate_reference_id = - Some(payments_api::MandateReferenceId::NetworkTokenWithNTI( - payments_api::NetworkTokenWithNTIRef { - network_transaction_id: nt_data.network_transaction_id.to_string(), - token_exp_month: nt_data.token_exp_month, - token_exp_year: nt_data.token_exp_year, - }, - )); - let chosen_connector_data = filtered_nt_supported_connectors - .first() - .ok_or(errors::ApiErrorResponse::IncorrectPaymentMethodConfiguration) - .attach_printable( - "no eligible connector found for token-based MIT payment", - )?; - - routing_data.routed_through = Some( - chosen_connector_data - .connector_data - .connector_name - .to_string(), - ); - - routing_data - .merchant_connector_id - .clone_from(&chosen_connector_data.connector_data.merchant_connector_id); - - payment_data.set_mandate_id(payments_api::MandateIds { - mandate_id: None, - mandate_reference_id, - }); - - Ok(ConnectorCallType::PreDetermined( - chosen_connector_data.clone(), - )) - } - None => { - decide_connector_for_normal_or_recurring_payment( - state, - payment_data, - routing_data, - connectors, - is_connector_agnostic_mit_enabled, - &payment_method_info, - ) - .await - } - } + payment_data.set_mandate_id(payments_api::MandateIds { + mandate_id: None, + mandate_reference_id, + }); + Ok(ConnectorCallType::Retryable(retryable_connectors)) } ( None, @@ -9112,6 +9088,78 @@ where } } +#[cfg(feature = "v1")] +pub fn get_mandate_reference_id( + action_type: Option, + connector_routing_data: api::ConnectorRoutingData, + payment_data: &mut D, + payment_method_info: &domain::PaymentMethod, +) -> RouterResult> +where + D: OperationSessionGetters + OperationSessionSetters + Send + Sync + Clone, +{ + let mandate_reference_id = match action_type { + Some(ActionType::NetworkTokenWithNetworkTransactionId(network_token_data)) => { + logger::info!("using network token with network_transaction_id for MIT flow"); + + Some(payments_api::MandateReferenceId::NetworkTokenWithNTI( + network_token_data.into(), + )) + } + Some(ActionType::CardWithNetworkTransactionId(network_transaction_id)) => { + logger::info!("using card with network_transaction_id for MIT flow"); + + Some(payments_api::MandateReferenceId::NetworkMandateId( + network_transaction_id, + )) + } + Some(ActionType::ConnectorMandate(connector_mandate_details)) => { + logger::info!("using connector_mandate_id for MIT flow"); + let merchant_connector_id = connector_routing_data + .connector_data + .merchant_connector_id + .as_ref() + .ok_or_else(|| { + report!(errors::ApiErrorResponse::IncorrectPaymentMethodConfiguration) + .attach_printable("No eligible connector found for token-based MIT flow: no connector mandate details") + })?; + + let mandate_reference_record = connector_mandate_details + .get(merchant_connector_id) + .ok_or_else(|| { + report!(errors::ApiErrorResponse::IncorrectPaymentMethodConfiguration) + .attach_printable("No mandate record found for merchant connector ID") + })?; + + if let Some(mandate_currency) = + mandate_reference_record.original_payment_authorized_currency + { + if mandate_currency != payment_data.get_currency() { + return Err(report!(errors::ApiErrorResponse::MandateValidationFailed { + reason: "Cross currency mandates not supported".into(), + })); + } + } + + payment_data.set_recurring_mandate_payment_data(mandate_reference_record.into()); + + Some(payments_api::MandateReferenceId::ConnectorMandateId( + api_models::payments::ConnectorMandateReferenceId::new( + Some(mandate_reference_record.connector_mandate_id.clone()), + Some(payment_method_info.get_id().clone()), + None, + mandate_reference_record.mandate_metadata.clone(), + mandate_reference_record + .connector_mandate_request_reference_id + .clone(), + ), + )) + } + None => None, + }; + Ok(mandate_reference_id) +} + #[cfg(feature = "v1")] #[allow(clippy::too_many_arguments)] pub async fn decide_connector_for_normal_or_recurring_payment( @@ -9178,16 +9226,8 @@ where ) )); payment_data.set_recurring_mandate_payment_data( - hyperswitch_domain_models::router_data::RecurringMandatePaymentData { - payment_method_type: mandate_reference_record - .payment_method_type, - original_payment_authorized_amount: mandate_reference_record - .original_payment_authorized_amount, - original_payment_authorized_currency: mandate_reference_record - .original_payment_authorized_currency, - mandate_metadata: mandate_reference_record - .mandate_metadata.clone() - }); + mandate_reference_record.into(), + ); connector_choice = Some((connector_data, mandate_reference_id.clone())); break; } @@ -9260,9 +9300,23 @@ pub struct NTWithNTIRef { pub token_exp_year: Option>, } -#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Eq, PartialEq)] +impl From for payments_api::NetworkTokenWithNTIRef { + fn from(network_token_data: NTWithNTIRef) -> Self { + Self { + network_transaction_id: network_token_data.network_transaction_id, + token_exp_month: network_token_data.token_exp_month, + token_exp_year: network_token_data.token_exp_year, + } + } +} + +// This represents the recurring details of a connector which will be used for retries +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)] pub enum ActionType { NetworkTokenWithNetworkTransactionId(NTWithNTIRef), + CardWithNetworkTransactionId(String), // Network Transaction Id + #[cfg(feature = "v1")] + ConnectorMandate(hyperswitch_domain_models::mandates::PaymentsMandateReference), } pub fn filter_network_tokenization_supported_connectors( @@ -9278,40 +9332,154 @@ pub fn filter_network_tokenization_supported_connectors( } #[cfg(feature = "v1")] -pub async fn decide_action_type( +#[derive(Default)] +pub struct ActionTypesBuilder { + action_types: Vec, +} + +#[cfg(feature = "v1")] +impl ActionTypesBuilder { + pub fn new() -> Self { + Self { + action_types: Vec::new(), + } + } + + pub fn with_mandate_flow( + mut self, + is_mandate_flow: bool, + connector_mandate_details: Option< + hyperswitch_domain_models::mandates::PaymentsMandateReference, + >, + ) -> Self { + if is_mandate_flow { + self.action_types.extend( + connector_mandate_details + .map(|details| ActionType::ConnectorMandate(details.to_owned())), + ); + } + self + } + + pub async fn with_network_tokenization( + mut self, + state: &SessionState, + is_network_token_with_ntid_flow: IsNtWithNtiFlow, + is_nt_with_ntid_supported_connector: bool, + payment_method_info: &domain::PaymentMethod, + ) -> Self { + match is_network_token_with_ntid_flow { + IsNtWithNtiFlow::NtWithNtiSupported(network_transaction_id) + if is_nt_with_ntid_supported_connector => + { + self.action_types.extend( + network_tokenization::do_status_check_for_network_token( + state, + payment_method_info, + ) + .await + .inspect_err(|e| { + logger::error!("Status check for network token failed: {:?}", e) + }) + .ok() + .map(|(token_exp_month, token_exp_year)| { + ActionType::NetworkTokenWithNetworkTransactionId(NTWithNTIRef { + token_exp_month, + token_exp_year, + network_transaction_id, + }) + }), + ); + } + _ => (), + } + self + } + + pub fn with_card_network_transaction_id( + mut self, + is_card_with_ntid_flow: bool, + payment_method_info: &domain::PaymentMethod, + ) -> Self { + if is_card_with_ntid_flow { + self.action_types.extend( + payment_method_info + .network_transaction_id + .as_ref() + .map(|ntid| ActionType::CardWithNetworkTransactionId(ntid.clone())), + ); + } + self + } + + pub fn build(self) -> Vec { + self.action_types + } +} + +#[cfg(feature = "v1")] +pub async fn get_all_action_types( state: &SessionState, is_connector_agnostic_mit_enabled: Option, is_network_tokenization_enabled: bool, payment_method_info: &domain::PaymentMethod, - filtered_nt_supported_connectors: Vec, //network tokenization supported connectors -) -> Option { - match ( - is_network_token_with_network_transaction_id_flow( - is_connector_agnostic_mit_enabled, - is_network_tokenization_enabled, + connector: api::ConnectorData, +) -> Vec { + let merchant_connector_id = connector.merchant_connector_id.as_ref(); + + //fetch connectors that support ntid flow + let ntid_supported_connectors = &state + .conf + .network_transaction_id_supported_connectors + .connector_list; + + //fetch connectors that support network tokenization flow + let network_tokenization_supported_connectors = &state + .conf + .network_tokenization_supported_connectors + .connector_list; + + let is_network_token_with_ntid_flow = is_network_token_with_network_transaction_id_flow( + is_connector_agnostic_mit_enabled, + is_network_tokenization_enabled, + payment_method_info, + ); + let is_card_with_ntid_flow = is_network_transaction_id_flow( + state, + is_connector_agnostic_mit_enabled, + connector.connector_name, + payment_method_info, + ); + let payments_mandate_reference = payment_method_info + .get_common_mandate_reference() + .map_err(|err| { + logger::warn!("Error getting connector mandate details: {:?}", err); + err + }) + .ok() + .and_then(|details| details.payments); + + let is_mandate_flow = payments_mandate_reference + .clone() + .zip(merchant_connector_id) + .map(|(details, merchant_connector_id)| details.contains_key(merchant_connector_id)) + .unwrap_or(false); + + let is_nt_with_ntid_supported_connector = ntid_supported_connectors + .contains(&connector.connector_name) + && network_tokenization_supported_connectors.contains(&connector.connector_name); + + ActionTypesBuilder::new() + .with_mandate_flow(is_mandate_flow, payments_mandate_reference) + .with_network_tokenization( + state, + is_network_token_with_ntid_flow, + is_nt_with_ntid_supported_connector, payment_method_info, - ), - !filtered_nt_supported_connectors.is_empty(), - ) { - (IsNtWithNtiFlow::NtWithNtiSupported(network_transaction_id), true) => { - if let Ok((token_exp_month, token_exp_year)) = - network_tokenization::do_status_check_for_network_token(state, payment_method_info) - .await - { - Some(ActionType::NetworkTokenWithNetworkTransactionId( - NTWithNTIRef { - token_exp_month, - token_exp_year, - network_transaction_id, - }, - )) - } else { - None - } - } - (IsNtWithNtiFlow::NtWithNtiSupported(_), false) - | (IsNtWithNtiFlow::NTWithNTINotSupported, _) => None, - } + ) + .await + .with_card_network_transaction_id(is_card_with_ntid_flow, payment_method_info) + .build() } pub fn is_network_transaction_id_flow( diff --git a/crates/router/src/core/payments/retry.rs b/crates/router/src/core/payments/retry.rs index 89bfe11e91..e8b7a9914b 100644 --- a/crates/router/src/core/payments/retry.rs +++ b/crates/router/src/core/payments/retry.rs @@ -3,6 +3,7 @@ use std::vec::IntoIter; use common_utils::{ext_traits::Encode, types::MinorUnit}; use diesel_models::enums as storage_enums; use error_stack::ResultExt; +use hyperswitch_domain_models::ext_traits::OptionExt; use router_env::{ logger, tracing::{self, instrument}, @@ -159,7 +160,31 @@ where && clear_pan_possible && business_profile.is_clear_pan_retries_enabled; - let (connector, routing_decision) = if should_retry_with_pan { + // Currently we are taking off_session as a source of truth to identify MIT payments. + let is_mit_payment = payment_data + .get_payment_intent() + .off_session + .unwrap_or(false); + + let (connector, routing_decision) = if is_mit_payment { + let connector_routing_data = + super::get_connector_data(&mut connector_routing_data)?; + let payment_method_info = payment_data + .get_payment_method_info() + .get_required_value("payment_method_info")? + .clone(); + let mandate_reference_id = payments::get_mandate_reference_id( + connector_routing_data.action_type.clone(), + connector_routing_data.clone(), + payment_data, + &payment_method_info, + )?; + payment_data.set_mandate_id(api_models::payments::MandateIds { + mandate_id: None, + mandate_reference_id, //mandate_ref_id + }); + (connector_routing_data.connector_data, None) + } else if should_retry_with_pan { // If should_retry_with_pan is true, it indicates that we are retrying with PAN using the same connector. (original_connector_data.clone(), None) } else { diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index 362daa8296..c54241e6b5 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -107,6 +107,7 @@ impl From for ConnectorRoutingData { Self { connector_data, network: None, + action_type: None, } } } diff --git a/crates/router/src/types/api/fraud_check.rs b/crates/router/src/types/api/fraud_check.rs index b0ed7ed35d..411e53a296 100644 --- a/crates/router/src/types/api/fraud_check.rs +++ b/crates/router/src/types/api/fraud_check.rs @@ -16,7 +16,11 @@ pub use super::fraud_check_v2::{ FraudCheckTransactionV2, FraudCheckV2, }; use super::{ConnectorData, SessionConnectorDatas}; -use crate::{connector, core::errors, services::connector_integration_interface::ConnectorEnum}; +use crate::{ + connector, + core::{errors, payments::ActionType}, + services::connector_integration_interface::ConnectorEnum, +}; #[derive(Clone)] pub struct FraudCheckConnectorData { @@ -33,6 +37,8 @@ pub enum ConnectorCallType { pub struct ConnectorRoutingData { pub connector_data: ConnectorData, pub network: Option, + // action_type is used for mandates currently + pub action_type: Option, } impl FraudCheckConnectorData {