From ec6d0e4d62a530163ae1c806dcf40ccfd264b246 Mon Sep 17 00:00:00 2001 From: Prasunna Soppa <70575890+prasunna09@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:43:04 +0530 Subject: [PATCH] feat(router): Add webhooks for network tokenization (#6695) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/payment_methods.rs | 17 + crates/api_models/src/webhooks.rs | 24 +- crates/common_enums/src/enums.rs | 20 + crates/common_types/src/callback_mapper.rs | 40 ++ crates/common_types/src/lib.rs | 3 + crates/common_utils/src/events.rs | 4 + crates/diesel_models/src/callback_mapper.rs | 7 +- .../src/callback_mapper.rs | 29 +- .../src/configs/secrets_transformers.rs | 4 + crates/router/src/configs/settings.rs | 1 + crates/router/src/configs/validations.rs | 11 +- crates/router/src/core/payment_methods.rs | 6 +- .../router/src/core/payments/tokenization.rs | 38 +- crates/router/src/core/webhooks.rs | 4 +- crates/router/src/core/webhooks/incoming.rs | 145 +++++- .../webhooks/network_tokenization_incoming.rs | 463 ++++++++++++++++++ crates/router/src/db.rs | 1 + crates/router/src/db/callback_mapper.rs | 21 +- crates/router/src/routes/app.rs | 18 + crates/router/src/routes/lock_utils.rs | 3 +- crates/router/src/routes/webhooks.rs | 29 ++ crates/router/src/types/domain.rs | 2 +- crates/router/src/types/payment_methods.rs | 33 +- crates/router_env/src/logger/types.rs | 2 + crates/storage_impl/src/callback_mapper.rs | 4 +- 25 files changed, 903 insertions(+), 26 deletions(-) create mode 100644 crates/common_types/src/callback_mapper.rs create mode 100644 crates/router/src/core/webhooks/network_tokenization_incoming.rs diff --git a/crates/api_models/src/payment_methods.rs b/crates/api_models/src/payment_methods.rs index 25b583f5dc..c681cf7edd 100644 --- a/crates/api_models/src/payment_methods.rs +++ b/crates/api_models/src/payment_methods.rs @@ -1065,6 +1065,23 @@ pub struct Card { pub nick_name: Option, } +#[cfg(feature = "v1")] +impl From<(Card, Option)> for CardDetail { + fn from((card, card_network): (Card, Option)) -> Self { + Self { + card_number: card.card_number.clone(), + card_exp_month: card.card_exp_month.clone(), + card_exp_year: card.card_exp_year.clone(), + card_holder_name: card.name_on_card.clone(), + nick_name: card.nick_name.map(masking::Secret::new), + card_issuing_country: None, + card_network, + card_issuer: None, + card_type: None, + } + } +} + #[cfg(feature = "v1")] #[derive(Debug, serde::Deserialize, serde::Serialize, Clone, ToSchema)] pub struct CardDetailFromLocker { diff --git a/crates/api_models/src/webhooks.rs b/crates/api_models/src/webhooks.rs index 7895588e8e..4667866073 100644 --- a/crates/api_models/src/webhooks.rs +++ b/crates/api_models/src/webhooks.rs @@ -129,6 +129,11 @@ pub enum WebhookResponseTracker { mandate_id: String, status: common_enums::MandateStatus, }, + #[cfg(feature = "v1")] + PaymentMethod { + payment_method_id: String, + status: common_enums::PaymentMethodStatus, + }, NoEffect, Relay { relay_id: common_utils::id_type::RelayId, @@ -143,13 +148,30 @@ impl WebhookResponseTracker { Self::Payment { payment_id, .. } | Self::Refund { payment_id, .. } | Self::Dispute { payment_id, .. } => Some(payment_id.to_owned()), - Self::NoEffect | Self::Mandate { .. } => None, + Self::NoEffect | Self::Mandate { .. } | Self::PaymentMethod { .. } => None, #[cfg(feature = "payouts")] Self::Payout { .. } => None, Self::Relay { .. } => None, } } + #[cfg(feature = "v1")] + pub fn get_payment_method_id(&self) -> Option { + match self { + Self::PaymentMethod { + payment_method_id, .. + } => Some(payment_method_id.to_owned()), + Self::Payment { .. } + | Self::Refund { .. } + | Self::Dispute { .. } + | Self::NoEffect + | Self::Mandate { .. } + | Self::Relay { .. } => None, + #[cfg(feature = "payouts")] + Self::Payout { .. } => None, + } + } + #[cfg(feature = "v2")] pub fn get_payment_id(&self) -> Option { match self { diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index d7f4a1b2a2..d36b2d624f 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -8535,3 +8535,23 @@ impl RoutingApproach { } } } + +#[derive( + Clone, + Copy, + Debug, + Eq, + PartialEq, + serde::Serialize, + serde::Deserialize, + ToSchema, + strum::Display, + strum::EnumString, + Hash, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[router_derive::diesel_enum(storage_type = "text")] +pub enum CallbackMapperIdType { + NetworkTokenRequestorRefernceID, +} diff --git a/crates/common_types/src/callback_mapper.rs b/crates/common_types/src/callback_mapper.rs new file mode 100644 index 0000000000..c7af35d1c7 --- /dev/null +++ b/crates/common_types/src/callback_mapper.rs @@ -0,0 +1,40 @@ +use common_utils::id_type; +use diesel::{AsExpression, FromSqlRow}; + +#[derive( + Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, AsExpression, FromSqlRow, +)] +#[diesel(sql_type = diesel::sql_types::Jsonb)] +/// Represents the data associated with a callback mapper. +pub enum CallbackMapperData { + /// data variant used while processing the network token webhook + NetworkTokenWebhook { + /// Merchant id assiociated with the network token requestor reference id + merchant_id: id_type::MerchantId, + /// Payment Method id assiociated with the network token requestor reference id + payment_method_id: String, + /// Customer id assiociated with the network token requestor reference id + customer_id: id_type::CustomerId, + }, +} + +impl CallbackMapperData { + /// Retrieves the details of the network token webhook type from callback mapper data. + pub fn get_network_token_webhook_details( + &self, + ) -> (id_type::MerchantId, String, id_type::CustomerId) { + match self { + Self::NetworkTokenWebhook { + merchant_id, + payment_method_id, + customer_id, + } => ( + merchant_id.clone(), + payment_method_id.clone(), + customer_id.clone(), + ), + } + } +} + +common_utils::impl_to_sql_from_sql_json!(CallbackMapperData); diff --git a/crates/common_types/src/lib.rs b/crates/common_types/src/lib.rs index 4913f1ebcc..91ee876184 100644 --- a/crates/common_types/src/lib.rs +++ b/crates/common_types/src/lib.rs @@ -12,3 +12,6 @@ pub mod primitive_wrappers; pub mod refunds; /// types for three ds decision rule engine pub mod three_ds_decision_rule_engine; + +///types for callback mapper +pub mod callback_mapper; diff --git a/crates/common_utils/src/events.rs b/crates/common_utils/src/events.rs index 506211c4d7..376b57fbb4 100644 --- a/crates/common_utils/src/events.rs +++ b/crates/common_utils/src/events.rs @@ -75,6 +75,10 @@ pub enum ApiEventsType { connector: String, payment_id: Option, }, + #[cfg(feature = "v1")] + NetworkTokenWebhook { + payment_method_id: Option, + }, #[cfg(feature = "v2")] Webhooks { connector: id_type::MerchantConnectorAccountId, diff --git a/crates/diesel_models/src/callback_mapper.rs b/crates/diesel_models/src/callback_mapper.rs index 3e031d483a..f9a8fa47c8 100644 --- a/crates/diesel_models/src/callback_mapper.rs +++ b/crates/diesel_models/src/callback_mapper.rs @@ -1,4 +1,5 @@ -use common_utils::pii; +use common_enums::enums as common_enums; +use common_types::callback_mapper::CallbackMapperData; use diesel::{Identifiable, Insertable, Queryable, Selectable}; use crate::schema::callback_mapper; @@ -7,8 +8,8 @@ use crate::schema::callback_mapper; #[diesel(table_name = callback_mapper, primary_key(id, type_), check_for_backend(diesel::pg::Pg))] pub struct CallbackMapper { pub id: String, - pub type_: String, - pub data: pii::SecretSerdeValue, + pub type_: common_enums::CallbackMapperIdType, + pub data: CallbackMapperData, pub created_at: time::PrimitiveDateTime, pub last_modified_at: time::PrimitiveDateTime, } diff --git a/crates/hyperswitch_domain_models/src/callback_mapper.rs b/crates/hyperswitch_domain_models/src/callback_mapper.rs index fffc33e572..429f3f7ff9 100644 --- a/crates/hyperswitch_domain_models/src/callback_mapper.rs +++ b/crates/hyperswitch_domain_models/src/callback_mapper.rs @@ -1,12 +1,29 @@ -use common_utils::pii; -use serde::{self, Deserialize, Serialize}; +use common_enums::enums as common_enums; +use common_types::callback_mapper::CallbackMapperData; -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct CallbackMapper { pub id: String, - #[serde(rename = "type")] - pub type_: String, - pub data: pii::SecretSerdeValue, + pub callback_mapper_id_type: common_enums::CallbackMapperIdType, + pub data: CallbackMapperData, pub created_at: time::PrimitiveDateTime, pub last_modified_at: time::PrimitiveDateTime, } + +impl CallbackMapper { + pub fn new( + id: String, + callback_mapper_id_type: common_enums::CallbackMapperIdType, + data: CallbackMapperData, + created_at: time::PrimitiveDateTime, + last_modified_at: time::PrimitiveDateTime, + ) -> Self { + Self { + id, + callback_mapper_id_type, + data, + created_at, + last_modified_at, + } + } +} diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs index b4c579a07a..9129a9a78f 100644 --- a/crates/router/src/configs/secrets_transformers.rs +++ b/crates/router/src/configs/secrets_transformers.rs @@ -314,11 +314,15 @@ impl SecretsHandler for settings::NetworkTokenizationService { let private_key = secret_management_client .get_secret(network_tokenization.private_key.clone()) .await?; + let webhook_source_verification_key = secret_management_client + .get_secret(network_tokenization.webhook_source_verification_key.clone()) + .await?; Ok(value.transition_state(|network_tokenization| Self { public_key, private_key, token_service_api_key, + webhook_source_verification_key, ..network_tokenization })) } diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 9424b2572d..8a2132a7fe 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -601,6 +601,7 @@ pub struct NetworkTokenizationService { pub key_id: String, pub delete_token_url: url::Url, pub check_token_status_url: url::Url, + pub webhook_source_verification_key: Secret, } #[derive(Debug, Deserialize, Clone, Default)] diff --git a/crates/router/src/configs/validations.rs b/crates/router/src/configs/validations.rs index 1b8adfaa8b..ab5f2af416 100644 --- a/crates/router/src/configs/validations.rs +++ b/crates/router/src/configs/validations.rs @@ -244,7 +244,16 @@ impl super::settings::NetworkTokenizationService { Err(ApplicationError::InvalidConfigurationValueError( "private_key must not be empty".into(), )) - }) + })?; + + when( + self.webhook_source_verification_key.is_default_or_empty(), + || { + Err(ApplicationError::InvalidConfigurationValueError( + "webhook_source_verification_key must not be empty".into(), + )) + }, + ) } } diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs index 59be79c66f..2034119fa2 100644 --- a/crates/router/src/core/payment_methods.rs +++ b/crates/router/src/core/payment_methods.rs @@ -20,16 +20,16 @@ use api_models::payment_methods; #[cfg(feature = "payouts")] pub use api_models::{enums::PayoutConnectors, payouts as payout_types}; #[cfg(feature = "v1")] -use common_utils::ext_traits::{Encode, OptionExt}; -use common_utils::{consts::DEFAULT_LOCALE, id_type}; +use common_utils::{consts::DEFAULT_LOCALE, ext_traits::OptionExt}; #[cfg(feature = "v2")] use common_utils::{ crypto::Encryptable, errors::CustomResult, - ext_traits::{AsyncExt, Encode, ValueExt}, + ext_traits::{AsyncExt, ValueExt}, fp_utils::when, generate_id, types as util_types, }; +use common_utils::{ext_traits::Encode, id_type}; use diesel_models::{ enums, GenericLinkNew, PaymentMethodCollectLink, PaymentMethodCollectLinkData, }; diff --git a/crates/router/src/core/payments/tokenization.rs b/crates/router/src/core/payments/tokenization.rs index b88476e19d..ef9e8b580d 100644 --- a/crates/router/src/core/payments/tokenization.rs +++ b/crates/router/src/core/payments/tokenization.rs @@ -7,6 +7,7 @@ use api_models::{ payment_methods::PaymentMethodDataWalletInfo, payments::ConnectorMandateReferenceId, }; use common_enums::{ConnectorMandateStatus, PaymentMethod}; +use common_types::callback_mapper::CallbackMapperData; use common_utils::{ crypto::Encryptable, ext_traits::{AsyncExt, Encode, ValueExt}, @@ -17,6 +18,7 @@ use common_utils::{ use error_stack::{report, ResultExt}; #[cfg(feature = "v1")] use hyperswitch_domain_models::{ + callback_mapper::CallbackMapper, mandates::{CommonMandateReference, PaymentsMandateReference, PaymentsMandateReferenceRecord}, payment_method_data, }; @@ -754,11 +756,45 @@ where card.card_network .map(|card_network| card_network.to_string()) }), - network_token_requestor_ref_id, + network_token_requestor_ref_id.clone(), network_token_locker_id, pm_network_token_data_encrypted, ) .await?; + + match network_token_requestor_ref_id { + Some(network_token_requestor_ref_id) => { + //Insert the network token reference ID along with merchant id, customer id in CallbackMapper table for its respective webooks + let callback_mapper_data = + CallbackMapperData::NetworkTokenWebhook { + merchant_id: merchant_context + .get_merchant_account() + .get_id() + .clone(), + customer_id, + payment_method_id: resp.payment_method_id.clone(), + }; + let callback_mapper = CallbackMapper::new( + network_token_requestor_ref_id, + common_enums::CallbackMapperIdType::NetworkTokenRequestorRefernceID, + callback_mapper_data, + common_utils::date_time::now(), + common_utils::date_time::now(), + ); + + db.insert_call_back_mapper(callback_mapper) + .await + .change_context( + errors::ApiErrorResponse::InternalServerError, + ) + .attach_printable( + "Failed to insert in Callback Mapper table", + )?; + } + None => { + logger::info!("Network token requestor reference ID is not available, skipping callback mapper insertion"); + } + }; }; } } diff --git a/crates/router/src/core/webhooks.rs b/crates/router/src/core/webhooks.rs index 298cf93613..88a3361ec0 100644 --- a/crates/router/src/core/webhooks.rs +++ b/crates/router/src/core/webhooks.rs @@ -3,6 +3,8 @@ mod incoming; #[cfg(feature = "v2")] mod incoming_v2; #[cfg(feature = "v1")] +mod network_tokenization_incoming; +#[cfg(feature = "v1")] mod outgoing; #[cfg(feature = "v2")] mod outgoing_v2; @@ -15,7 +17,7 @@ pub mod webhook_events; #[cfg(feature = "v1")] pub(crate) use self::{ - incoming::incoming_webhooks_wrapper, + incoming::{incoming_webhooks_wrapper, network_token_incoming_webhooks_wrapper}, outgoing::{ create_event_and_trigger_outgoing_webhook, get_outgoing_webhook_request, trigger_webhook_and_raise_event, diff --git a/crates/router/src/core/webhooks/incoming.rs b/crates/router/src/core/webhooks/incoming.rs index 183296766d..51297b4387 100644 --- a/crates/router/src/core/webhooks/incoming.rs +++ b/crates/router/src/core/webhooks/incoming.rs @@ -7,7 +7,7 @@ use api_models::webhooks::{self, WebhookResponseTracker}; use common_utils::{ errors::ReportSwitchExt, events::ApiEventsType, - ext_traits::AsyncExt, + ext_traits::{AsyncExt, ByteSliceExt}, types::{AmountConvertor, StringMinorUnitForConnector}, }; use diesel_models::{refund as diesel_refund, ConnectorMandateReferenceId}; @@ -28,10 +28,10 @@ use crate::{ core::{ api_locking, errors::{self, ConnectorErrorExt, CustomResult, RouterResponse, StorageErrorExt}, - metrics, + metrics, payment_methods, payments::{self, tokenization}, refunds, relay, utils as core_utils, - webhooks::utils::construct_webhook_router_data, + webhooks::{network_tokenization_incoming, utils::construct_webhook_router_data}, }, db::StorageInterface, events::api_logs::ApiEvent, @@ -125,6 +125,68 @@ pub async fn incoming_webhooks_wrapper( Ok(application_response) } +#[cfg(feature = "v1")] +pub async fn network_token_incoming_webhooks_wrapper( + flow: &impl router_env::types::FlowMetric, + state: SessionState, + req: &actix_web::HttpRequest, + body: actix_web::web::Bytes, +) -> RouterResponse { + let start_instant = Instant::now(); + + let request_details: IncomingWebhookRequestDetails<'_> = IncomingWebhookRequestDetails { + method: req.method().clone(), + uri: req.uri().clone(), + headers: req.headers(), + query_params: req.query_string().to_string(), + body: &body, + }; + + let (application_response, webhooks_response_tracker, serialized_req, merchant_id) = Box::pin( + network_token_incoming_webhooks_core::(&state, request_details), + ) + .await?; + + logger::info!(incoming_webhook_payload = ?serialized_req); + + let request_duration = Instant::now() + .saturating_duration_since(start_instant) + .as_millis(); + + let request_id = RequestId::extract(req) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Unable to extract request id from request")?; + let auth_type = auth::AuthenticationType::NoAuth; + let status_code = 200; + let api_event = ApiEventsType::NetworkTokenWebhook { + payment_method_id: webhooks_response_tracker.get_payment_method_id(), + }; + let response_value = serde_json::to_value(&webhooks_response_tracker) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Could not convert webhook effect to string")?; + let infra = state.infra_components.clone(); + let api_event = ApiEvent::new( + state.tenant.tenant_id.clone(), + Some(merchant_id), + flow, + &request_id, + request_duration, + status_code, + serialized_req, + Some(response_value), + None, + auth_type, + None, + api_event, + req, + req.method(), + infra, + ); + state.event_handler().log_event(&api_event); + Ok(application_response) +} + #[allow(clippy::too_many_arguments)] #[instrument(skip_all)] async fn incoming_webhooks_core( @@ -598,6 +660,81 @@ fn handle_incoming_webhook_error( } } +#[instrument(skip_all)] +#[cfg(feature = "v1")] +async fn network_token_incoming_webhooks_core( + state: &SessionState, + request_details: IncomingWebhookRequestDetails<'_>, +) -> errors::RouterResult<( + services::ApplicationResponse, + WebhookResponseTracker, + serde_json::Value, + common_utils::id_type::MerchantId, +)> { + let serialized_request = + network_tokenization_incoming::get_network_token_resource_object(&request_details) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Network Token Requestor Webhook deserialization failed")? + .masked_serialize() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Could not convert webhook effect to string")?; + + let network_tokenization_service = &state + .conf + .network_tokenization_service + .as_ref() + .ok_or(errors::NetworkTokenizationError::NetworkTokenizationServiceNotConfigured) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Network Tokenization Service not configured")?; + + //source verification + network_tokenization_incoming::Authorization::new(request_details.headers.get("Authorization")) + .verify_webhook_source(network_tokenization_service.get_inner()) + .await?; + + let response: network_tokenization_incoming::NetworkTokenWebhookResponse = request_details + .body + .parse_struct("NetworkTokenWebhookResponse") + .change_context(errors::ApiErrorResponse::WebhookUnprocessableEntity)?; + + let (merchant_id, payment_method_id, _customer_id) = response + .fetch_merchant_id_payment_method_id_customer_id_from_callback_mapper(state) + .await?; + + metrics::WEBHOOK_SOURCE_VERIFIED_COUNT.add( + 1, + router_env::metric_attributes!((MERCHANT_ID, merchant_id.clone())), + ); + + let merchant_context = + network_tokenization_incoming::fetch_merchant_account_for_network_token_webhooks( + state, + &merchant_id, + ) + .await?; + let payment_method = + network_tokenization_incoming::fetch_payment_method_for_network_token_webhooks( + state, + merchant_context.get_merchant_account(), + merchant_context.get_merchant_key_store(), + &payment_method_id, + ) + .await?; + + let response_data = response.get_response_data(); + + let webhook_resp_tracker = response_data + .update_payment_method(state, &payment_method, &merchant_context) + .await?; + + Ok(( + services::ApplicationResponse::StatusOk, + webhook_resp_tracker, + serialized_request, + merchant_id.clone(), + )) +} + #[allow(clippy::too_many_arguments)] #[instrument(skip_all)] async fn payments_incoming_webhook_flow( @@ -1316,7 +1453,7 @@ async fn external_authentication_incoming_webhook_flow( authentication_details .authentication_value .async_map(|auth_val| { - crate::core::payment_methods::vault::create_tokenize( + payment_methods::vault::create_tokenize( &state, auth_val.expose(), None, diff --git a/crates/router/src/core/webhooks/network_tokenization_incoming.rs b/crates/router/src/core/webhooks/network_tokenization_incoming.rs new file mode 100644 index 0000000000..a49ec40cba --- /dev/null +++ b/crates/router/src/core/webhooks/network_tokenization_incoming.rs @@ -0,0 +1,463 @@ +use std::str::FromStr; + +use ::payment_methods::controller::PaymentMethodsController; +use api_models::webhooks::WebhookResponseTracker; +use async_trait::async_trait; +use common_utils::{ + crypto::Encryptable, + ext_traits::{AsyncExt, ByteSliceExt, ValueExt}, + id_type, +}; +use error_stack::{report, ResultExt}; +use http::HeaderValue; +use masking::{ExposeInterface, Secret}; +use serde::{Deserialize, Serialize}; + +use crate::{ + configs::settings, + core::{ + errors::{self, CustomResult, RouterResult, StorageErrorExt}, + payment_methods::cards, + }, + logger, + routes::{app::SessionStateInfo, SessionState}, + types::{ + api, domain, payment_methods as pm_types, + storage::{self, enums}, + }, + utils::{self as helper_utils, ext_traits::OptionExt}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum NetworkTokenWebhookResponse { + PanMetadataUpdate(pm_types::PanMetadataUpdateBody), + NetworkTokenMetadataUpdate(pm_types::NetworkTokenMetaDataUpdateBody), +} + +impl NetworkTokenWebhookResponse { + fn get_network_token_requestor_ref_id(&self) -> String { + match self { + Self::PanMetadataUpdate(data) => data.card.card_reference.clone(), + Self::NetworkTokenMetadataUpdate(data) => data.token.card_reference.clone(), + } + } + + pub fn get_response_data(self) -> Box { + match self { + Self::PanMetadataUpdate(data) => Box::new(data), + Self::NetworkTokenMetadataUpdate(data) => Box::new(data), + } + } + + pub async fn fetch_merchant_id_payment_method_id_customer_id_from_callback_mapper( + &self, + state: &SessionState, + ) -> RouterResult<(id_type::MerchantId, String, id_type::CustomerId)> { + let network_token_requestor_ref_id = &self.get_network_token_requestor_ref_id(); + + let db = &*state.store; + let callback_mapper_data = db + .find_call_back_mapper_by_id(network_token_requestor_ref_id) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to fetch callback mapper data")?; + + Ok(callback_mapper_data + .data + .get_network_token_webhook_details()) + } +} + +pub fn get_network_token_resource_object( + request_details: &api::IncomingWebhookRequestDetails<'_>, +) -> CustomResult, errors::NetworkTokenizationError> { + let response: NetworkTokenWebhookResponse = request_details + .body + .parse_struct("NetworkTokenWebhookResponse") + .change_context(errors::NetworkTokenizationError::ResponseDeserializationFailed)?; + Ok(Box::new(response)) +} + +#[async_trait] +pub trait NetworkTokenWebhookResponseExt { + fn decrypt_payment_method_data( + &self, + payment_method: &domain::PaymentMethod, + ) -> CustomResult; + + async fn update_payment_method( + &self, + state: &SessionState, + payment_method: &domain::PaymentMethod, + merchant_context: &domain::MerchantContext, + ) -> CustomResult; +} + +#[async_trait] +impl NetworkTokenWebhookResponseExt for pm_types::PanMetadataUpdateBody { + fn decrypt_payment_method_data( + &self, + payment_method: &domain::PaymentMethod, + ) -> CustomResult { + let decrypted_data = payment_method + .payment_method_data + .clone() + .map(|payment_method_data| payment_method_data.into_inner().expose()) + .and_then(|val| { + val.parse_value::("PaymentMethodsData") + .map_err(|err| logger::error!(?err, "Failed to parse PaymentMethodsData")) + .ok() + }) + .and_then(|pmd| match pmd { + api::payment_methods::PaymentMethodsData::Card(token) => { + Some(api::payment_methods::CardDetailFromLocker::from(token)) + } + _ => None, + }) + .ok_or(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to obtain decrypted token object from db")?; + Ok(decrypted_data) + } + + async fn update_payment_method( + &self, + state: &SessionState, + payment_method: &domain::PaymentMethod, + merchant_context: &domain::MerchantContext, + ) -> CustomResult { + let decrypted_data = self.decrypt_payment_method_data(payment_method)?; + handle_metadata_update( + state, + &self.card, + payment_method + .locker_id + .clone() + .get_required_value("locker_id") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Locker id is not found for the payment method")?, + payment_method, + merchant_context, + decrypted_data, + true, + ) + .await + } +} + +#[async_trait] +impl NetworkTokenWebhookResponseExt for pm_types::NetworkTokenMetaDataUpdateBody { + fn decrypt_payment_method_data( + &self, + payment_method: &domain::PaymentMethod, + ) -> CustomResult { + let decrypted_data = payment_method + .network_token_payment_method_data + .clone() + .map(|x| x.into_inner().expose()) + .and_then(|val| { + val.parse_value::("PaymentMethodsData") + .map_err(|err| logger::error!(?err, "Failed to parse PaymentMethodsData")) + .ok() + }) + .and_then(|pmd| match pmd { + api::payment_methods::PaymentMethodsData::Card(token) => { + Some(api::payment_methods::CardDetailFromLocker::from(token)) + } + _ => None, + }) + .ok_or(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to obtain decrypted token object from db")?; + Ok(decrypted_data) + } + + async fn update_payment_method( + &self, + state: &SessionState, + payment_method: &domain::PaymentMethod, + merchant_context: &domain::MerchantContext, + ) -> CustomResult { + let decrypted_data = self.decrypt_payment_method_data(payment_method)?; + handle_metadata_update( + state, + &self.token, + payment_method + .network_token_locker_id + .clone() + .get_required_value("locker_id") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Locker id is not found for the payment method")?, + payment_method, + merchant_context, + decrypted_data, + true, + ) + .await + } +} + +pub struct Authorization { + header: Option, +} + +impl Authorization { + pub fn new(header: Option<&HeaderValue>) -> Self { + Self { + header: header.cloned(), + } + } + + pub async fn verify_webhook_source( + self, + nt_service: &settings::NetworkTokenizationService, + ) -> CustomResult<(), errors::ApiErrorResponse> { + let secret = nt_service.webhook_source_verification_key.clone(); + + let source_verified = match self.header { + Some(authorization_header) => match authorization_header.to_str() { + Ok(header_value) => Ok(header_value == secret.expose()), + Err(err) => { + logger::error!(?err, "Failed to parse authorization header"); + Err(errors::ApiErrorResponse::WebhookAuthenticationFailed) + } + }, + None => Ok(false), + }?; + logger::info!(source_verified=?source_verified); + + helper_utils::when(!source_verified, || { + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + })?; + + Ok(()) + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn handle_metadata_update( + state: &SessionState, + metadata: &pm_types::NetworkTokenRequestorData, + locker_id: String, + payment_method: &domain::PaymentMethod, + merchant_context: &domain::MerchantContext, + decrypted_data: api::payment_methods::CardDetailFromLocker, + is_pan_update: bool, +) -> RouterResult { + let merchant_id = merchant_context.get_merchant_account().get_id(); + let customer_id = &payment_method.customer_id; + let payment_method_id = payment_method.get_id().clone(); + let status = payment_method.status; + + match metadata.is_update_required(decrypted_data) { + false => { + logger::info!( + "No update required for payment method {} for locker_id {}", + payment_method.get_id(), + locker_id + ); + Ok(WebhookResponseTracker::PaymentMethod { + payment_method_id, + status, + }) + } + true => { + let mut card = cards::get_card_from_locker(state, customer_id, merchant_id, &locker_id) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to fetch token information from the locker")?; + + card.card_exp_year = metadata.expiry_year.clone(); + card.card_exp_month = metadata.expiry_month.clone(); + + let card_network = card + .card_brand + .clone() + .map(|card_brand| enums::CardNetwork::from_str(&card_brand)) + .transpose() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "card network", + }) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Invalid Card Network stored in vault")?; + + let card_data = api::payment_methods::CardDetail::from((card, card_network)); + + let payment_method_request: api::payment_methods::PaymentMethodCreate = + PaymentMethodCreateWrapper::from((&card_data, payment_method)).get_inner(); + + let pm_cards = cards::PmCards { + state, + merchant_context, + }; + + pm_cards + .delete_card_from_locker(customer_id, merchant_id, &locker_id) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to delete network token")?; + + let (res, _) = pm_cards + .add_card_to_locker(payment_method_request, &card_data, customer_id, None) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to add network token")?; + + let pm_details = res.card.as_ref().map(|card| { + api::payment_methods::PaymentMethodsData::Card( + api::payment_methods::CardDetailsPaymentMethod::from((card.clone(), None)), + ) + }); + let key_manager_state = state.into(); + + let pm_data_encrypted: Option>> = pm_details + .async_map(|pm_card| { + cards::create_encrypted_data( + &key_manager_state, + merchant_context.get_merchant_key_store(), + pm_card, + ) + }) + .await + .transpose() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Unable to encrypt payment method data")?; + + let pm_update = if is_pan_update { + storage::PaymentMethodUpdate::AdditionalDataUpdate { + locker_id: Some(res.payment_method_id), + payment_method_data: pm_data_encrypted.map(Into::into), + status: None, + payment_method: None, + payment_method_type: None, + payment_method_issuer: None, + network_token_requestor_reference_id: None, + network_token_locker_id: None, + network_token_payment_method_data: None, + } + } else { + storage::PaymentMethodUpdate::AdditionalDataUpdate { + locker_id: None, + payment_method_data: None, + status: None, + payment_method: None, + payment_method_type: None, + payment_method_issuer: None, + network_token_requestor_reference_id: None, + network_token_locker_id: Some(res.payment_method_id), + network_token_payment_method_data: pm_data_encrypted.map(Into::into), + } + }; + let db = &*state.store; + + db.update_payment_method( + &key_manager_state, + merchant_context.get_merchant_key_store(), + payment_method.clone(), + pm_update, + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to update the payment method")?; + + Ok(WebhookResponseTracker::PaymentMethod { + payment_method_id, + status, + }) + } + } +} + +pub struct PaymentMethodCreateWrapper(pub api::payment_methods::PaymentMethodCreate); + +impl From<(&api::payment_methods::CardDetail, &domain::PaymentMethod)> + for PaymentMethodCreateWrapper +{ + fn from( + (data, payment_method): (&api::payment_methods::CardDetail, &domain::PaymentMethod), + ) -> Self { + Self(api::payment_methods::PaymentMethodCreate { + customer_id: Some(payment_method.customer_id.clone()), + payment_method: payment_method.payment_method, + payment_method_type: payment_method.payment_method_type, + payment_method_issuer: payment_method.payment_method_issuer.clone(), + payment_method_issuer_code: payment_method.payment_method_issuer_code, + metadata: payment_method.metadata.clone(), + payment_method_data: None, + connector_mandate_details: None, + client_secret: None, + billing: None, + card: Some(data.clone()), + card_network: data + .card_network + .clone() + .map(|card_network| card_network.to_string()), + bank_transfer: None, + wallet: None, + network_transaction_id: payment_method.network_transaction_id.clone(), + }) + } +} + +impl PaymentMethodCreateWrapper { + fn get_inner(self) -> api::payment_methods::PaymentMethodCreate { + self.0 + } +} + +pub async fn fetch_merchant_account_for_network_token_webhooks( + state: &SessionState, + merchant_id: &id_type::MerchantId, +) -> RouterResult { + let db = &*state.store; + let key_manager_state = &(state).into(); + + let key_store = state + .store() + .get_merchant_key_store_by_merchant_id( + key_manager_state, + merchant_id, + &state.store().get_master_key().to_vec().into(), + ) + .await + .change_context(errors::ApiErrorResponse::Unauthorized) + .attach_printable("Failed to fetch merchant key store for the merchant id")?; + + let merchant_account = db + .find_merchant_account_by_merchant_id(key_manager_state, merchant_id, &key_store) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to fetch merchant account for the merchant id")?; + + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new(domain::Context( + merchant_account.clone(), + key_store, + ))); + + Ok(merchant_context) +} + +pub async fn fetch_payment_method_for_network_token_webhooks( + state: &SessionState, + merchant_account: &domain::MerchantAccount, + key_store: &domain::MerchantKeyStore, + payment_method_id: &str, +) -> RouterResult { + let db = &*state.store; + let key_manager_state = &(state).into(); + + let payment_method = db + .find_payment_method( + key_manager_state, + key_store, + payment_method_id, + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable("Failed to fetch the payment method")?; + + Ok(payment_method) +} diff --git a/crates/router/src/db.rs b/crates/router/src/db.rs index d77e192626..59c31d4b2b 100644 --- a/crates/router/src/db.rs +++ b/crates/router/src/db.rs @@ -142,6 +142,7 @@ pub trait StorageInterface: + user::theme::ThemeInterface + payment_method_session::PaymentMethodsSessionInterface + tokenization::TokenizationInterface + + callback_mapper::CallbackMapperInterface + 'static { fn get_scheduler_db(&self) -> Box; diff --git a/crates/router/src/db/callback_mapper.rs b/crates/router/src/db/callback_mapper.rs index 0697f41bda..70e9bd30cc 100644 --- a/crates/router/src/db/callback_mapper.rs +++ b/crates/router/src/db/callback_mapper.rs @@ -1,7 +1,7 @@ use error_stack::report; use hyperswitch_domain_models::callback_mapper as domain; use router_env::{instrument, tracing}; -use storage_impl::DataModelExt; +use storage_impl::{DataModelExt, MockDb}; use super::Store; use crate::{ @@ -51,3 +51,22 @@ impl CallbackMapperInterface for Store { .map(domain::CallbackMapper::from_storage_model) } } + +#[async_trait::async_trait] +impl CallbackMapperInterface for MockDb { + #[instrument(skip_all)] + async fn insert_call_back_mapper( + &self, + _call_back_mapper: domain::CallbackMapper, + ) -> CustomResult { + Err(errors::StorageError::MockDbError)? + } + + #[instrument(skip_all)] + async fn find_call_back_mapper_by_id( + &self, + _id: &str, + ) -> CustomResult { + Err(errors::StorageError::MockDbError)? + } +} diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 8656d3f289..3152a666ac 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1710,6 +1710,24 @@ impl Webhooks { #[allow(unused_mut)] let mut route = web::scope("/webhooks") .app_data(web::Data::new(config)) + .service( + web::resource("/network_token_requestor/ref") + .route( + web::post().to(receive_network_token_requestor_incoming_webhook::< + webhook_type::OutgoingWebhook, + >), + ) + .route( + web::get().to(receive_network_token_requestor_incoming_webhook::< + webhook_type::OutgoingWebhook, + >), + ) + .route( + web::put().to(receive_network_token_requestor_incoming_webhook::< + webhook_type::OutgoingWebhook, + >), + ), + ) .service( web::resource("/{merchant_id}/{connector_id_or_name}") .route( diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index c1f3533ef1..fa616a3da3 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -192,7 +192,8 @@ impl From for ApiIdentifier { | Flow::WebhookEventInitialDeliveryAttemptList | Flow::WebhookEventDeliveryAttemptList | Flow::WebhookEventDeliveryRetry - | Flow::RecoveryIncomingWebhookReceive => Self::Webhooks, + | Flow::RecoveryIncomingWebhookReceive + | Flow::IncomingNetworkTokenWebhookReceive => Self::Webhooks, Flow::ApiKeyCreate | Flow::ApiKeyRetrieve diff --git a/crates/router/src/routes/webhooks.rs b/crates/router/src/routes/webhooks.rs index d19f48b62f..41188288ea 100644 --- a/crates/router/src/routes/webhooks.rs +++ b/crates/router/src/routes/webhooks.rs @@ -179,3 +179,32 @@ pub async fn receive_incoming_webhook( )) .await } + +#[cfg(feature = "v1")] +#[instrument(skip_all, fields(flow = ?Flow::IncomingNetworkTokenWebhookReceive))] +pub async fn receive_network_token_requestor_incoming_webhook( + state: web::Data, + req: HttpRequest, + body: web::Bytes, + _path: web::Path, +) -> impl Responder { + let flow = Flow::IncomingNetworkTokenWebhookReceive; + + Box::pin(api::server_wrap( + flow.clone(), + state, + &req, + (), + |state, _: (), _, _| { + webhooks::network_token_incoming_webhooks_wrapper::( + &flow, + state.to_owned(), + &req, + body.clone(), + ) + }, + &auth::NoAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/types/domain.rs b/crates/router/src/types/domain.rs index 3e94375b5e..63c27e4300 100644 --- a/crates/router/src/types/domain.rs +++ b/crates/router/src/types/domain.rs @@ -19,7 +19,7 @@ mod customers { pub use hyperswitch_domain_models::customer::*; } -mod callback_mapper { +pub mod callback_mapper { pub use hyperswitch_domain_models::callback_mapper::CallbackMapper; } diff --git a/crates/router/src/types/payment_methods.rs b/crates/router/src/types/payment_methods.rs index 29814ddcef..0d205f33e8 100644 --- a/crates/router/src/types/payment_methods.rs +++ b/crates/router/src/types/payment_methods.rs @@ -15,10 +15,11 @@ use hyperswitch_domain_models::payment_method_data::NetworkTokenDetails; use masking::Secret; use serde::{Deserialize, Serialize}; +use crate::types::api; #[cfg(feature = "v2")] use crate::{ consts, - types::{api, domain, storage}, + types::{domain, storage}, }; #[cfg(feature = "v2")] @@ -357,3 +358,33 @@ pub struct CheckTokenStatusResponsePayload { pub struct CheckTokenStatusResponse { pub payload: CheckTokenStatusResponsePayload, } + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NetworkTokenRequestorData { + pub card_reference: String, + pub customer_id: String, + pub expiry_year: Secret, + pub expiry_month: Secret, +} + +impl NetworkTokenRequestorData { + pub fn is_update_required( + &self, + data_stored_in_vault: api::payment_methods::CardDetailFromLocker, + ) -> bool { + //if the expiry year and month in the vault are not the same as the ones in the requestor data, + //then we need to update the vault data with the updated expiry year and month. + !((data_stored_in_vault.expiry_year.unwrap_or_default() == self.expiry_year) + && (data_stored_in_vault.expiry_month.unwrap_or_default() == self.expiry_month)) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NetworkTokenMetaDataUpdateBody { + pub token: NetworkTokenRequestorData, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PanMetadataUpdateBody { + pub card: NetworkTokenRequestorData, +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 4e0bdab3ce..d395cd1f6d 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -606,6 +606,8 @@ pub enum Flow { ProfileAcquirerUpdate, /// ThreeDs Decision Rule Execute flow ThreeDsDecisionRuleExecute, + /// Incoming Network Token Webhook Receive + IncomingNetworkTokenWebhookReceive, } /// Trait for providing generic behaviour to flow metric diff --git a/crates/storage_impl/src/callback_mapper.rs b/crates/storage_impl/src/callback_mapper.rs index 186f2b7f92..4f22dd079e 100644 --- a/crates/storage_impl/src/callback_mapper.rs +++ b/crates/storage_impl/src/callback_mapper.rs @@ -9,7 +9,7 @@ impl DataModelExt for CallbackMapper { fn to_storage_model(self) -> Self::StorageModel { DieselCallbackMapper { id: self.id, - type_: self.type_, + type_: self.callback_mapper_id_type, data: self.data, created_at: self.created_at, last_modified_at: self.last_modified_at, @@ -19,7 +19,7 @@ impl DataModelExt for CallbackMapper { fn from_storage_model(storage_model: Self::StorageModel) -> Self { Self { id: storage_model.id, - type_: storage_model.type_, + callback_mapper_id_type: storage_model.type_, data: storage_model.data, created_at: storage_model.created_at, last_modified_at: storage_model.last_modified_at,