From da90d74bfac2f9d2ef19971dfd22b3bdba646948 Mon Sep 17 00:00:00 2001 From: Sagnik Mitra <83326850+ImSagnik007@users.noreply.github.com> Date: Mon, 19 May 2025 15:52:12 +0530 Subject: [PATCH] feat(core): [Network Tokenization] pre network tokenization (#6873) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/admin.rs | 11 + crates/diesel_models/src/business_profile.rs | 6 + crates/diesel_models/src/schema.rs | 1 + .../src/business_profile.rs | 17 + .../src/payment_method_data.rs | 34 ++ .../hyperswitch_domain_models/src/payments.rs | 20 + crates/router/src/core/admin.rs | 4 + crates/router/src/core/payments.rs | 111 ++++- crates/router/src/core/payments/helpers.rs | 6 +- .../payments/operations/payment_response.rs | 11 +- .../router/src/core/payments/tokenization.rs | 439 ++++++++++++++---- .../router/src/core/payments/transformers.rs | 21 +- crates/router/src/types/api/admin.rs | 4 + .../down.sql | 2 + .../up.sql | 2 + .../down.sql | 2 + .../up.sql | 2 + 17 files changed, 593 insertions(+), 100 deletions(-) create mode 100644 migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/down.sql create mode 100644 migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/up.sql create mode 100644 v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/down.sql create mode 100644 v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/up.sql diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index a7b57b7433..60eca62b61 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -2002,6 +2002,9 @@ pub struct ProfileCreate { /// Indicates if the redirection has to open in the iframe pub is_iframe_redirection_enabled: Option, + + /// Indicates if pre network tokenization is enabled or not + pub is_pre_network_tokenization_enabled: Option, } #[nutype::nutype( @@ -2302,6 +2305,10 @@ pub struct ProfileResponse { //Merchant country for the profile #[schema(value_type = Option, example = "US")] pub merchant_business_country: Option, + + /// Indicates if pre network tokenization is enabled or not + #[schema(default = false, example = false)] + pub is_pre_network_tokenization_enabled: bool, } #[cfg(feature = "v2")] @@ -2602,6 +2609,10 @@ pub struct ProfileUpdate { /// Indicates if the redirection has to open in the iframe pub is_iframe_redirection_enabled: Option, + + /// Indicates if pre network tokenization is enabled or not + #[schema(default = false, example = false)] + pub is_pre_network_tokenization_enabled: Option, } #[cfg(feature = "v2")] diff --git a/crates/diesel_models/src/business_profile.rs b/crates/diesel_models/src/business_profile.rs index 9309bb4287..a7c606fb94 100644 --- a/crates/diesel_models/src/business_profile.rs +++ b/crates/diesel_models/src/business_profile.rs @@ -71,6 +71,7 @@ pub struct Profile { pub merchant_business_country: Option, pub id: Option, pub is_iframe_redirection_enabled: Option, + pub is_pre_network_tokenization_enabled: Option, } #[cfg(feature = "v1")] @@ -125,6 +126,7 @@ pub struct ProfileNew { pub merchant_business_country: Option, pub id: Option, pub is_iframe_redirection_enabled: Option, + pub is_pre_network_tokenization_enabled: Option, } #[cfg(feature = "v1")] @@ -177,6 +179,7 @@ pub struct ProfileUpdateInternal { pub is_debit_routing_enabled: bool, pub merchant_business_country: Option, pub is_iframe_redirection_enabled: Option, + pub is_pre_network_tokenization_enabled: Option, } #[cfg(feature = "v1")] @@ -226,6 +229,7 @@ impl ProfileUpdateInternal { is_debit_routing_enabled, merchant_business_country, is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled, } = self; Profile { profile_id: source.profile_id, @@ -303,6 +307,8 @@ impl ProfileUpdateInternal { .or(source.merchant_business_country), is_iframe_redirection_enabled: is_iframe_redirection_enabled .or(source.is_iframe_redirection_enabled), + is_pre_network_tokenization_enabled: is_pre_network_tokenization_enabled + .or(source.is_pre_network_tokenization_enabled), } } } diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index f890963c18..5994670e1d 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -226,6 +226,7 @@ diesel::table! { #[max_length = 64] id -> Nullable, is_iframe_redirection_enabled -> Nullable, + is_pre_network_tokenization_enabled -> Nullable, } } diff --git a/crates/hyperswitch_domain_models/src/business_profile.rs b/crates/hyperswitch_domain_models/src/business_profile.rs index 9f70f46238..2c8b2e9979 100644 --- a/crates/hyperswitch_domain_models/src/business_profile.rs +++ b/crates/hyperswitch_domain_models/src/business_profile.rs @@ -74,6 +74,7 @@ pub struct Profile { pub is_debit_routing_enabled: bool, pub merchant_business_country: Option, pub is_iframe_redirection_enabled: Option, + pub is_pre_network_tokenization_enabled: bool, } #[cfg(feature = "v1")] @@ -126,6 +127,7 @@ pub struct ProfileSetter { pub is_debit_routing_enabled: bool, pub merchant_business_country: Option, pub is_iframe_redirection_enabled: Option, + pub is_pre_network_tokenization_enabled: bool, } #[cfg(feature = "v1")] @@ -183,6 +185,7 @@ impl From for Profile { is_debit_routing_enabled: value.is_debit_routing_enabled, merchant_business_country: value.merchant_business_country, is_iframe_redirection_enabled: value.is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled: value.is_pre_network_tokenization_enabled, } } } @@ -242,6 +245,7 @@ pub struct ProfileGeneralUpdate { pub is_debit_routing_enabled: bool, pub merchant_business_country: Option, pub is_iframe_redirection_enabled: Option, + pub is_pre_network_tokenization_enabled: Option, } #[cfg(feature = "v1")] @@ -316,6 +320,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled, merchant_business_country, is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled, } = *update; Self { @@ -363,6 +368,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled, merchant_business_country, is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled, } } ProfileUpdate::RoutingAlgorithmUpdate { @@ -412,6 +418,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled: false, merchant_business_country: None, is_iframe_redirection_enabled: None, + is_pre_network_tokenization_enabled: None, }, ProfileUpdate::DynamicRoutingAlgorithmUpdate { dynamic_routing_algorithm, @@ -459,6 +466,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled: false, merchant_business_country: None, is_iframe_redirection_enabled: None, + is_pre_network_tokenization_enabled: None, }, ProfileUpdate::ExtendedCardInfoUpdate { is_extended_card_info_enabled, @@ -506,6 +514,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled: false, merchant_business_country: None, is_iframe_redirection_enabled: None, + is_pre_network_tokenization_enabled: None, }, ProfileUpdate::ConnectorAgnosticMitUpdate { is_connector_agnostic_mit_enabled, @@ -553,6 +562,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled: false, merchant_business_country: None, is_iframe_redirection_enabled: None, + is_pre_network_tokenization_enabled: None, }, ProfileUpdate::NetworkTokenizationUpdate { is_network_tokenization_enabled, @@ -600,6 +610,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled: false, merchant_business_country: None, is_iframe_redirection_enabled: None, + is_pre_network_tokenization_enabled: None, }, ProfileUpdate::CardTestingSecretKeyUpdate { card_testing_secret_key, @@ -647,6 +658,7 @@ impl From for ProfileUpdateInternal { is_debit_routing_enabled: false, merchant_business_country: None, is_iframe_redirection_enabled: None, + is_pre_network_tokenization_enabled: None, }, } } @@ -714,6 +726,7 @@ impl super::behaviour::Conversion for Profile { is_debit_routing_enabled: self.is_debit_routing_enabled, merchant_business_country: self.merchant_business_country, is_iframe_redirection_enabled: self.is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled: Some(self.is_pre_network_tokenization_enabled), }) } @@ -805,6 +818,9 @@ impl super::behaviour::Conversion for Profile { is_debit_routing_enabled: item.is_debit_routing_enabled, merchant_business_country: item.merchant_business_country, is_iframe_redirection_enabled: item.is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled: item + .is_pre_network_tokenization_enabled + .unwrap_or(false), }) } .await @@ -867,6 +883,7 @@ impl super::behaviour::Conversion for Profile { is_debit_routing_enabled: self.is_debit_routing_enabled, merchant_business_country: self.merchant_business_country, is_iframe_redirection_enabled: self.is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled: Some(self.is_pre_network_tokenization_enabled), }) } } diff --git a/crates/hyperswitch_domain_models/src/payment_method_data.rs b/crates/hyperswitch_domain_models/src/payment_method_data.rs index 4ddb32b4d6..5ba90a9561 100644 --- a/crates/hyperswitch_domain_models/src/payment_method_data.rs +++ b/crates/hyperswitch_domain_models/src/payment_method_data.rs @@ -2037,3 +2037,37 @@ impl SingleUseTokenKey { &self.0 } } + +#[cfg(feature = "v1")] +impl From for payment_methods::CardDetail { + fn from(card_data: Card) -> Self { + Self { + card_number: card_data.card_number.clone(), + card_exp_month: card_data.card_exp_month.clone(), + card_exp_year: card_data.card_exp_year.clone(), + card_holder_name: None, + nick_name: None, + card_issuing_country: None, + card_network: card_data.card_network.clone(), + card_issuer: None, + card_type: None, + } + } +} + +#[cfg(feature = "v1")] +impl From for payment_methods::CardDetail { + fn from(network_token_data: NetworkTokenData) -> Self { + Self { + card_number: network_token_data.token_number.clone(), + card_exp_month: network_token_data.token_exp_month.clone(), + card_exp_year: network_token_data.token_exp_year.clone(), + card_holder_name: None, + nick_name: None, + card_issuing_country: None, + card_network: network_token_data.card_network.clone(), + card_issuer: None, + card_type: None, + } + } +} diff --git a/crates/hyperswitch_domain_models/src/payments.rs b/crates/hyperswitch_domain_models/src/payments.rs index 103ccd593f..047fe7fb2c 100644 --- a/crates/hyperswitch_domain_models/src/payments.rs +++ b/crates/hyperswitch_domain_models/src/payments.rs @@ -999,9 +999,29 @@ where } } +#[derive(Default, Clone, serde::Serialize, Debug)] +pub struct CardAndNetworkTokenDataForVault { + pub card_data: payment_method_data::Card, + pub network_token: NetworkTokenDataForVault, +} + +#[derive(Default, Clone, serde::Serialize, Debug)] +pub struct NetworkTokenDataForVault { + pub network_token_data: payment_method_data::NetworkTokenData, + pub network_token_req_ref_id: String, +} + +#[derive(Default, Clone, serde::Serialize, Debug)] +pub struct CardDataForVault { + pub card_data: payment_method_data::Card, + pub network_token_req_ref_id: Option, +} + #[derive(Clone, serde::Serialize, Debug)] pub enum VaultOperation { ExistingVaultData(VaultData), + SaveCardData(CardDataForVault), + SaveCardAndNetworkTokenData(Box), } impl VaultOperation { diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index b359248f90..14a6900693 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -3929,6 +3929,9 @@ impl ProfileCreateBridge for api::ProfileCreate { is_debit_routing_enabled: self.is_debit_routing_enabled.unwrap_or_default(), merchant_business_country: self.merchant_business_country, is_iframe_redirection_enabled: self.is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled: self + .is_pre_network_tokenization_enabled + .unwrap_or_default(), })) } @@ -4378,6 +4381,7 @@ impl ProfileUpdateBridge for api::ProfileUpdate { is_debit_routing_enabled: self.is_debit_routing_enabled.unwrap_or_default(), merchant_business_country: self.merchant_business_country, is_iframe_redirection_enabled: self.is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled: self.is_pre_network_tokenization_enabled, }, ))) } diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index cb68ece203..c6b7de6732 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -55,7 +55,7 @@ pub use hyperswitch_domain_models::{ router_request_types::CustomerDetails, }; use hyperswitch_domain_models::{ - payments::{payment_intent::CustomerData, ClickToPayMetaData}, + payments::{self, payment_intent::CustomerData, ClickToPayMetaData}, router_data::AccessToken, }; use masking::{ExposeInterface, PeekInterface, Secret}; @@ -488,6 +488,7 @@ where } _ => (), }; + payment_data = match connector_details { ConnectorCallType::PreDetermined(ref connector) => { #[cfg(all(feature = "dynamic_routing", feature = "v1"))] @@ -508,6 +509,7 @@ where } else { None }; + let (router_data, mca) = call_connector_service( state, req_state.clone(), @@ -3097,6 +3099,53 @@ where ) .await?; + let customer_acceptance = payment_data + .get_payment_attempt() + .customer_acceptance + .clone(); + + if is_pre_network_tokenization_enabled( + state, + business_profile, + customer_acceptance, + connector.connector_name, + ) { + let payment_method_data = payment_data.get_payment_method_data(); + let customer_id = payment_data.get_payment_intent().customer_id.clone(); + if let (Some(domain::PaymentMethodData::Card(card_data)), Some(customer_id)) = + (payment_method_data, customer_id) + { + let vault_operation = + get_vault_operation_for_pre_network_tokenization(state, customer_id, card_data) + .await; + match vault_operation { + payments::VaultOperation::SaveCardAndNetworkTokenData( + card_and_network_token_data, + ) => { + payment_data.set_vault_operation( + payments::VaultOperation::SaveCardAndNetworkTokenData(Box::new( + *card_and_network_token_data.clone(), + )), + ); + + payment_data.set_payment_method_data(Some( + domain::PaymentMethodData::NetworkToken( + card_and_network_token_data + .network_token + .network_token_data + .clone(), + ), + )); + } + payments::VaultOperation::SaveCardData(card_data_for_vault) => payment_data + .set_vault_operation(payments::VaultOperation::SaveCardData( + card_data_for_vault.clone(), + )), + payments::VaultOperation::ExistingVaultData(_) => (), + } + } + } + #[cfg(feature = "v1")] if payment_data .get_payment_attempt() @@ -4248,7 +4297,7 @@ pub async fn get_session_token_for_click_to_pay( merchant_id: &id_type::MerchantId, merchant_context: &domain::MerchantContext, authentication_product_ids: common_types::payments::AuthenticationConnectorAccountMap, - payment_intent: &hyperswitch_domain_models::payments::PaymentIntent, + payment_intent: &payments::PaymentIntent, profile_id: &id_type::ProfileId, ) -> RouterResult { let click_to_pay_mca_id = authentication_product_ids @@ -6169,6 +6218,64 @@ where Ok(()) } +#[cfg(feature = "v1")] +pub fn is_pre_network_tokenization_enabled( + state: &SessionState, + business_profile: &domain::Profile, + customer_acceptance: Option>, + connector_name: enums::Connector, +) -> bool { + let ntid_supported_connectors = &state + .conf + .network_transaction_id_supported_connectors + .connector_list; + + let is_nt_supported_connector = ntid_supported_connectors.contains(&connector_name); + + business_profile.is_network_tokenization_enabled + && business_profile.is_pre_network_tokenization_enabled + && customer_acceptance.is_some() + && is_nt_supported_connector +} + +#[cfg(feature = "v1")] +pub async fn get_vault_operation_for_pre_network_tokenization( + state: &SessionState, + customer_id: id_type::CustomerId, + card_data: &hyperswitch_domain_models::payment_method_data::Card, +) -> payments::VaultOperation { + let pre_tokenization_response = + tokenization::pre_payment_tokenization(state, customer_id, card_data) + .await + .ok(); + match pre_tokenization_response { + Some((Some(token_response), Some(token_ref))) => { + let token_data = domain::NetworkTokenData::from(token_response); + let network_token_data_for_vault = payments::NetworkTokenDataForVault { + network_token_data: token_data.clone(), + network_token_req_ref_id: token_ref, + }; + + payments::VaultOperation::SaveCardAndNetworkTokenData(Box::new( + payments::CardAndNetworkTokenDataForVault { + card_data: card_data.clone(), + network_token: network_token_data_for_vault.clone(), + }, + )) + } + Some((None, Some(token_ref))) => { + payments::VaultOperation::SaveCardData(payments::CardDataForVault { + card_data: card_data.clone(), + network_token_req_ref_id: Some(token_ref), + }) + } + _ => payments::VaultOperation::SaveCardData(payments::CardDataForVault { + card_data: card_data.clone(), + network_token_req_ref_id: None, + }), + } +} + #[cfg(feature = "v1")] #[allow(clippy::too_many_arguments)] pub async fn get_connector_choice( diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index 0cada8c368..a36c3c42fe 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -2618,8 +2618,10 @@ pub async fn make_pm_data<'a, F: Clone, R, D>( (_, Some(hyperswitch_token)) => { let existing_vault_data = payment_data.get_vault_operation(); - let vault_data = existing_vault_data.map(|data| match data { - domain_payments::VaultOperation::ExistingVaultData(vault_data) => vault_data, + let vault_data = existing_vault_data.and_then(|data| match data { + domain_payments::VaultOperation::ExistingVaultData(vault_data) => Some(vault_data), + domain_payments::VaultOperation::SaveCardData(_) + | domain_payments::VaultOperation::SaveCardAndNetworkTokenData(_) => None, }); let pm_data = Box::pin(payment_methods::retrieve_payment_method_with_token( diff --git a/crates/router/src/core/payments/operations/payment_response.rs b/crates/router/src/core/payments/operations/payment_response.rs index 0b182d249b..4c8edfb04e 100644 --- a/crates/router/src/core/payments/operations/payment_response.rs +++ b/crates/router/src/core/payments/operations/payment_response.rs @@ -170,6 +170,8 @@ impl PostUpdateTracker, types::PaymentsAuthor .and_then(|billing_details| billing_details.address.as_ref()) .and_then(|address| address.get_optional_full_name()); let mut should_avoid_saving = false; + let vault_operation = payment_data.vault_operation.clone(); + let payment_method_info = payment_data.payment_method_info.clone(); if let Some(payment_method_info) = &payment_data.payment_method_info { if payment_data.payment_intent.off_session.is_none() && resp.response.is_ok() { @@ -207,6 +209,8 @@ impl PostUpdateTracker, types::PaymentsAuthor business_profile, connector_mandate_reference_id.clone(), merchant_connector_id.clone(), + vault_operation.clone(), + payment_method_info.clone(), )); let is_connector_mandate = resp.request.customer_acceptance.is_some() @@ -321,6 +325,8 @@ impl PostUpdateTracker, types::PaymentsAuthor &business_profile, connector_mandate_reference_id, merchant_connector_id.clone(), + vault_operation.clone(), + payment_method_info.clone(), )) .await; @@ -1178,7 +1184,8 @@ impl PostUpdateTracker, types::SetupMandateRequestDa .connector_mandate_detail .as_ref() .map(|detail| ConnectorMandateReferenceId::foreign_from(detail.clone())); - + let vault_operation = payment_data.vault_operation.clone(); + let payment_method_info = payment_data.payment_method_info.clone(); let merchant_connector_id = payment_data.payment_attempt.merchant_connector_id.clone(); let tokenization::SavePaymentMethodDataResponse { payment_method_id, @@ -1196,6 +1203,8 @@ impl PostUpdateTracker, types::SetupMandateRequestDa business_profile, connector_mandate_reference_id, merchant_connector_id.clone(), + vault_operation, + payment_method_info, )) .await?; diff --git a/crates/router/src/core/payments/tokenization.rs b/crates/router/src/core/payments/tokenization.rs index ae68ea5377..dd5e39da0d 100644 --- a/crates/router/src/core/payments/tokenization.rs +++ b/crates/router/src/core/payments/tokenization.rs @@ -13,12 +13,15 @@ use common_enums::{ConnectorMandateStatus, PaymentMethod}; use common_utils::{ crypto::Encryptable, ext_traits::{AsyncExt, Encode, ValueExt}, - id_type, pii, + id_type, + metrics::utils::record_operation_time, + pii, }; use error_stack::{report, ResultExt}; #[cfg(feature = "v1")] -use hyperswitch_domain_models::mandates::{ - CommonMandateReference, PaymentsMandateReference, PaymentsMandateReferenceRecord, +use hyperswitch_domain_models::{ + mandates::{CommonMandateReference, PaymentsMandateReference, PaymentsMandateReferenceRecord}, + payment_method_data, }; use masking::{ExposeInterface, Secret}; use router_env::{instrument, tracing}; @@ -42,7 +45,7 @@ use crate::{ types::{ self, api::{self, CardDetailFromLocker, CardDetailsPaymentMethod, PaymentMethodCreateExt}, - domain, + domain, payment_methods as pm_types, storage::enums as storage_enums, }, utils::{generate_id, OptionExt}, @@ -92,6 +95,8 @@ pub async fn save_payment_method( business_profile: &domain::Profile, mut original_connector_mandate_reference_id: Option, merchant_connector_id: Option, + vault_operation: Option, + payment_method_info: Option, ) -> RouterResult where FData: mandate::MandateBehaviour + Clone, @@ -194,9 +199,11 @@ where }; let pm_id = if customer_acceptance.is_some() { + let payment_method_data = + save_payment_method_data.request.get_payment_method_data(); let payment_method_create_request = payment_methods::get_payment_method_create_request( - Some(&save_payment_method_data.request.get_payment_method_data()), + Some(&payment_method_data), Some(save_payment_method_data.payment_method), payment_method_type, &customer_id.clone(), @@ -224,42 +231,22 @@ where .await?; ((res, dc, None), None) } else { - pm_status = Some(common_enums::PaymentMethodStatus::from( + let payment_method_status = common_enums::PaymentMethodStatus::from( save_payment_method_data.attempt_status, - )); - let (res, dc) = Box::pin(save_in_locker( + ); + pm_status = Some(payment_method_status); + save_card_and_network_token_in_locker( state, + customer_id.clone(), + payment_method_status, + payment_method_data.clone(), + vault_operation, + payment_method_info, merchant_context, - payment_method_create_request.to_owned(), - )) - .await?; - - if is_network_tokenization_enabled { - let pm_data = &save_payment_method_data.request.get_payment_method_data(); - match pm_data { - domain::PaymentMethodData::Card(card) => { - let ( - network_token_resp, - _network_token_duplication_check, //the duplication check is discarded, since each card has only one token, handling card duplication check will be suffice - network_token_requestor_ref_id, - ) = Box::pin(save_network_token_in_locker( - state, - merchant_context, - card, - payment_method_create_request.clone(), - )) - .await?; - - ( - (res, dc, network_token_requestor_ref_id), - network_token_resp, - ) - } - _ => ((res, dc, None), None), //network_token_resp is None in case of other payment methods - } - } else { - ((res, dc, None), None) - } + payment_method_create_request.clone(), + is_network_tokenization_enabled, + ) + .await? }; let network_token_locker_id = match network_token_resp { Some(ref token_resp) => { @@ -272,10 +259,7 @@ where None => None, }; - let optional_pm_details = match ( - resp.card.as_ref(), - save_payment_method_data.request.get_payment_method_data(), - ) { + let optional_pm_details = match (resp.card.as_ref(), payment_method_data) { (Some(card), _) => Some(PaymentMethodsData::Card( CardDetailsPaymentMethod::from((card.clone(), co_badged_card_data)), )), @@ -849,6 +833,80 @@ where todo!() } +#[cfg(feature = "v1")] +pub async fn pre_payment_tokenization( + state: &SessionState, + customer_id: id_type::CustomerId, + card: &payment_method_data::Card, +) -> RouterResult<(Option, Option)> { + let network_tokenization_supported_card_networks = &state + .conf + .network_tokenization_supported_card_networks + .card_networks; + + if card + .card_network + .as_ref() + .filter(|cn| network_tokenization_supported_card_networks.contains(cn)) + .is_some() + { + let optional_card_cvc = Some(card.card_cvc.clone()); + let card_detail = payment_method_data::CardDetail::from(card); + match network_tokenization::make_card_network_tokenization_request( + state, + &card_detail, + optional_card_cvc, + &customer_id, + ) + .await + { + Ok((_token_response, network_token_requestor_ref_id)) => { + let network_tokenization_service = &state.conf.network_tokenization_service; + match ( + network_token_requestor_ref_id.clone(), + network_tokenization_service, + ) { + (Some(token_ref), Some(network_tokenization_service)) => { + let network_token = record_operation_time( + async { + network_tokenization::get_network_token( + state, + customer_id, + token_ref, + network_tokenization_service.get_inner(), + ) + .await + }, + &metrics::FETCH_NETWORK_TOKEN_TIME, + &[], + ) + .await; + match network_token { + Ok(token_response) => { + Ok((Some(token_response), network_token_requestor_ref_id.clone())) + } + _ => { + logger::error!( + "Error while fetching token from tokenization service" + ); + Ok((None, network_token_requestor_ref_id.clone())) + } + } + } + (Some(token_ref), _) => Ok((None, Some(token_ref))), + _ => Ok((None, None)), + } + } + Err(err) => { + logger::error!("Failed to tokenize card: {:?}", err); + Ok((None, None)) //None will be returned in case of error when calling network tokenization service + } + } + } else { + Ok((None, None)) //None will be returned in case of unsupported card network. + } +} + #[cfg(all( any(feature = "v1", feature = "v2"), not(feature = "payment_methods_v2") @@ -960,6 +1018,7 @@ pub async fn save_in_locker( state: &SessionState, merchant_context: &domain::MerchantContext, payment_method_request: api::PaymentMethodCreate, + card_detail: Option, ) -> RouterResult<( api_models::payment_methods::PaymentMethodResponse, Option, @@ -970,8 +1029,8 @@ pub async fn save_in_locker( .customer_id .clone() .get_required_value("customer_id")?; - match payment_method_request.card.clone() { - Some(card) => Box::pin( + match (payment_method_request.card.clone(), card_detail) { + (_, Some(card)) | (Some(card), _) => Box::pin( PmCards { state, merchant_context, @@ -981,7 +1040,7 @@ pub async fn save_in_locker( .await .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Add Card Failed"), - None => { + _ => { let pm_id = common_utils::generate_id(consts::ID_LENGTH, "pm"); let payment_method_response = api::PaymentMethodResponse { merchant_id: merchant_id.clone(), @@ -1038,7 +1097,8 @@ pub async fn save_network_token_in_locker( pub async fn save_network_token_in_locker( state: &SessionState, merchant_context: &domain::MerchantContext, - card_data: &domain::Card, + card_data: &payment_method_data::Card, + network_token_data: Option, payment_method_request: api::PaymentMethodCreate, ) -> RouterResult<( Option, @@ -1054,60 +1114,83 @@ pub async fn save_network_token_in_locker( .network_tokenization_supported_card_networks .card_networks; - if card_data - .card_network - .as_ref() - .filter(|cn| network_tokenization_supported_card_networks.contains(cn)) - .is_some() - { - let optional_card_cvc = Some(card_data.card_cvc.clone()); - match network_tokenization::make_card_network_tokenization_request( - state, - &domain::CardDetail::from(card_data), - optional_card_cvc, - &customer_id, - ) - .await - { - Ok((token_response, network_token_requestor_ref_id)) => { - // Only proceed if the tokenization was successful - let network_token_data = api::CardDetail { - card_number: token_response.token.clone(), - card_exp_month: token_response.token_expiry_month.clone(), - card_exp_year: token_response.token_expiry_year.clone(), - card_holder_name: None, - nick_name: None, - card_issuing_country: None, - card_network: Some(token_response.card_brand.clone()), - card_issuer: None, - card_type: None, - }; + match network_token_data { + Some(nt_data) => { + let (res, dc) = Box::pin( + PmCards { + state, + merchant_context, + } + .add_card_to_locker( + payment_method_request, + &nt_data, + &customer_id, + None, + ), + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Add Network Token Failed")?; - let (res, dc) = Box::pin( - PmCards { - state, - merchant_context, - } - .add_card_to_locker( - payment_method_request, - &network_token_data, - &customer_id, - None, - ), + Ok((Some(res), dc, None)) + } + None => { + if card_data + .card_network + .as_ref() + .filter(|cn| network_tokenization_supported_card_networks.contains(cn)) + .is_some() + { + let optional_card_cvc = Some(card_data.card_cvc.clone()); + match network_tokenization::make_card_network_tokenization_request( + state, + &domain::CardDetail::from(card_data), + optional_card_cvc, + &customer_id, ) .await - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Add Network Token Failed")?; + { + Ok((token_response, network_token_requestor_ref_id)) => { + // Only proceed if the tokenization was successful + let network_token_data = api::CardDetail { + card_number: token_response.token.clone(), + card_exp_month: token_response.token_expiry_month.clone(), + card_exp_year: token_response.token_expiry_year.clone(), + card_holder_name: None, + nick_name: None, + card_issuing_country: None, + card_network: Some(token_response.card_brand.clone()), + card_issuer: None, + card_type: None, + }; - Ok((Some(res), dc, network_token_requestor_ref_id)) - } - Err(err) => { - logger::error!("Failed to tokenize card: {:?}", err); - Ok((None, None, None)) //None will be returned in case of error when calling network tokenization service + let (res, dc) = Box::pin( + PmCards { + state, + merchant_context, + } + .add_card_to_locker( + payment_method_request, + &network_token_data, + &customer_id, + None, + ), + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Add Network Token Failed")?; + + Ok((Some(res), dc, network_token_requestor_ref_id)) + } + Err(err) => { + logger::error!("Failed to tokenize card: {:?}", err); + Ok((None, None, None)) //None will be returned in case of error when calling network tokenization service + } + } + } else { + Ok((None, None, None)) //None will be returned in case of unsupported card network. } } - } else { - Ok((None, None, None)) //None will be returned in case of unsupported card network. } } @@ -1450,3 +1533,171 @@ pub async fn add_token_for_payment_method( }), } } + +#[cfg(feature = "v1")] +#[allow(clippy::too_many_arguments)] +pub async fn save_card_and_network_token_in_locker( + state: &SessionState, + customer_id: id_type::CustomerId, + payment_method_status: common_enums::PaymentMethodStatus, + payment_method_data: domain::PaymentMethodData, + vault_operation: Option, + payment_method_info: Option, + merchant_context: &domain::MerchantContext, + payment_method_create_request: api::PaymentMethodCreate, + is_network_tokenization_enabled: bool, +) -> RouterResult<( + ( + api_models::payment_methods::PaymentMethodResponse, + Option, + Option, + ), + Option, +)> { + let network_token_requestor_reference_id = payment_method_info + .and_then(|pm_info| pm_info.network_token_requestor_reference_id.clone()); + + match vault_operation { + Some(hyperswitch_domain_models::payments::VaultOperation::SaveCardData(card)) => { + let card_data = api::CardDetail::from(card.card_data.clone()); + if let (Some(nt_ref_id), Some(tokenization_service)) = ( + card.network_token_req_ref_id.clone(), + &state.conf.network_tokenization_service, + ) { + let _ = record_operation_time( + async { + network_tokenization::delete_network_token_from_tokenization_service( + state, + nt_ref_id.clone(), + &customer_id, + tokenization_service.get_inner(), + ) + .await + }, + &metrics::DELETE_NETWORK_TOKEN_TIME, + &[], + ) + .await; + } + let (res, dc) = Box::pin(save_in_locker( + state, + merchant_context, + payment_method_create_request.to_owned(), + Some(card_data), + )) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Add Card In Locker Failed")?; + + Ok(((res, dc, None), None)) + } + Some(hyperswitch_domain_models::payments::VaultOperation::SaveCardAndNetworkTokenData( + save_card_and_network_token_data, + )) => { + let card_data = + api::CardDetail::from(save_card_and_network_token_data.card_data.clone()); + + let network_token_data = api::CardDetail::from( + save_card_and_network_token_data + .network_token + .network_token_data + .clone(), + ); + + if payment_method_status == common_enums::PaymentMethodStatus::Active { + let (res, dc) = Box::pin(save_in_locker( + state, + merchant_context, + payment_method_create_request.to_owned(), + Some(card_data), + )) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Add Card In Locker Failed")?; + + let (network_token_resp, _dc, _) = Box::pin(save_network_token_in_locker( + state, + merchant_context, + &save_card_and_network_token_data.card_data, + Some(network_token_data), + payment_method_create_request.clone(), + )) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Add Network Token In Locker Failed")?; + + Ok(( + (res, dc, network_token_requestor_reference_id), + network_token_resp, + )) + } else { + if let (Some(nt_ref_id), Some(tokenization_service)) = ( + network_token_requestor_reference_id.clone(), + &state.conf.network_tokenization_service, + ) { + let _ = record_operation_time( + async { + network_tokenization::delete_network_token_from_tokenization_service( + state, + nt_ref_id.clone(), + &customer_id, + tokenization_service.get_inner(), + ) + .await + }, + &metrics::DELETE_NETWORK_TOKEN_TIME, + &[], + ) + .await; + } + let (res, dc) = Box::pin(save_in_locker( + state, + merchant_context, + payment_method_create_request.to_owned(), + Some(card_data), + )) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Add Card In Locker Failed")?; + + Ok(((res, dc, None), None)) + } + } + _ => { + let (res, dc) = Box::pin(save_in_locker( + state, + merchant_context, + payment_method_create_request.to_owned(), + None, + )) + .await?; + + if is_network_tokenization_enabled { + match &payment_method_data { + domain::PaymentMethodData::Card(card) => { + let ( + network_token_resp, + _network_token_duplication_check, //the duplication check is discarded, since each card has only one token, handling card duplication check will be suffice + network_token_requestor_ref_id, + ) = Box::pin(save_network_token_in_locker( + state, + merchant_context, + card, + None, + payment_method_create_request.clone(), + )) + .await?; + + Ok(( + (res, dc, network_token_requestor_ref_id), + network_token_resp, + )) + } + _ => Ok(((res, dc, None), None)), //network_token_resp is None in case of other payment methods + } + } else { + Ok(((res, dc, None), None)) + } + } + } +} diff --git a/crates/router/src/core/payments/transformers.rs b/crates/router/src/core/payments/transformers.rs index 7e715d40ce..95aca37308 100644 --- a/crates/router/src/core/payments/transformers.rs +++ b/crates/router/src/core/payments/transformers.rs @@ -40,7 +40,7 @@ use crate::{ types::{ self, api::{self, ConnectorTransactionId}, - domain, + domain, payment_methods as pm_types, storage::{self, enums}, transformers::{ForeignFrom, ForeignInto, ForeignTryFrom}, MultipleCaptureRequestData, @@ -5159,3 +5159,22 @@ impl ForeignFrom<(Self, Option<&api_models::payments::AdditionalPaymentData>)> }) } } + +#[cfg(feature = "v1")] +impl From for domain::NetworkTokenData { + fn from(token_response: pm_types::TokenResponse) -> Self { + Self { + token_number: token_response.authentication_details.token, + token_exp_month: token_response.token_details.exp_month, + token_exp_year: token_response.token_details.exp_year, + token_cryptogram: Some(token_response.authentication_details.cryptogram), + card_issuer: None, + card_network: Some(token_response.network), + card_type: None, + card_issuing_country: None, + bank_code: None, + nick_name: None, + eci: None, + } + } +} diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs index bf7596adb4..50bd54756d 100644 --- a/crates/router/src/types/api/admin.rs +++ b/crates/router/src/types/api/admin.rs @@ -195,6 +195,7 @@ impl ForeignTryFrom for ProfileResponse { force_3ds_challenge: item.force_3ds_challenge, is_debit_routing_enabled: Some(item.is_debit_routing_enabled), merchant_business_country: item.merchant_business_country, + is_pre_network_tokenization_enabled: item.is_pre_network_tokenization_enabled, }) } } @@ -443,5 +444,8 @@ pub async fn create_profile_from_merchant_account( is_debit_routing_enabled: request.is_debit_routing_enabled.unwrap_or_default(), merchant_business_country: request.merchant_business_country, is_iframe_redirection_enabled: request.is_iframe_redirection_enabled, + is_pre_network_tokenization_enabled: request + .is_pre_network_tokenization_enabled + .unwrap_or_default(), })) } diff --git a/migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/down.sql b/migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/down.sql new file mode 100644 index 0000000000..6d40bb5573 --- /dev/null +++ b/migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE business_profile DROP COLUMN IF EXISTS is_pre_network_tokenization_enabled; \ No newline at end of file diff --git a/migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/up.sql b/migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/up.sql new file mode 100644 index 0000000000..23448365f3 --- /dev/null +++ b/migrations/2025-05-16-064616_add_is_pre_network_tokenization_enabled_in_business_profile/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE business_profile ADD COLUMN IF NOT EXISTS is_pre_network_tokenization_enabled BOOLEAN; \ No newline at end of file diff --git a/v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/down.sql b/v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/down.sql new file mode 100644 index 0000000000..d239f77f1a --- /dev/null +++ b/v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE business_profile ADD COLUMN IF NOT EXISTS is_pre_network_tokenization_enabled BOOLEAN; \ No newline at end of file diff --git a/v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/up.sql b/v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/up.sql new file mode 100644 index 0000000000..6810035010 --- /dev/null +++ b/v2_migrations/2025-05-05-073505_remove_is_pre_network_tokenization_enabled_from_business_profile/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE business_profile DROP COLUMN IF EXISTS is_pre_network_tokenization_enabled; \ No newline at end of file