From 4df84e913f5724491c948c283a022931c617f46f Mon Sep 17 00:00:00 2001 From: Prajjwal Kumar Date: Wed, 5 Jun 2024 20:42:22 +0530 Subject: [PATCH 1/9] Refactor(core): Inclusion of constraint graph for merchant Payment Method list (#4845) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Co-authored-by: shashank_attarde --- crates/euclid/src/enums.rs | 2 + crates/router/src/core/payment_methods.rs | 1 + .../router/src/core/payment_methods/cards.rs | 671 +++++----------- .../router/src/core/payment_methods/utils.rs | 733 ++++++++++++++++++ .../src/db/merchant_connector_account.rs | 6 + crates/storage_impl/src/redis/cache.rs | 12 + crates/storage_impl/src/redis/pub_sub.rs | 19 +- 7 files changed, 986 insertions(+), 458 deletions(-) create mode 100644 crates/router/src/core/payment_methods/utils.rs diff --git a/crates/euclid/src/enums.rs b/crates/euclid/src/enums.rs index 1aba6338d9d2..8e65d23d5ea9 100644 --- a/crates/euclid/src/enums.rs +++ b/crates/euclid/src/enums.rs @@ -79,6 +79,8 @@ pub enum MandateAcceptanceType { pub enum PaymentType { SetupMandate, NonMandate, + NewMandate, + UpdateMandate, } #[derive( diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs index b6d95248abc6..1172ed0eabc6 100644 --- a/crates/router/src/core/payment_methods.rs +++ b/crates/router/src/core/payment_methods.rs @@ -1,6 +1,7 @@ pub mod cards; pub mod surcharge_decision_configs; pub mod transformers; +pub mod utils; pub mod vault; pub use api_models::enums::Connector; diff --git a/crates/router/src/core/payment_methods/cards.rs b/crates/router/src/core/payment_methods/cards.rs index 210fdd947cf1..499eddafc1f3 100644 --- a/crates/router/src/core/payment_methods/cards.rs +++ b/crates/router/src/core/payment_methods/cards.rs @@ -5,7 +5,7 @@ use std::{ }; use api_models::{ - admin::{self, PaymentMethodsEnabled}, + admin::PaymentMethodsEnabled, enums::{self as api_enums}, payment_methods::{ BankAccountTokenData, Card, CardDetailUpdate, CardDetailsPaymentMethod, CardNetworkTypes, @@ -19,18 +19,22 @@ use api_models::{ pm_auth::PaymentMethodAuthConfig, surcharge_decision_configs as api_surcharge_decision_configs, }; +use cgraph::ConstraintGraph; use common_enums::enums::MerchantStorageScheme; use common_utils::{ consts, ext_traits::{AsyncExt, Encode, StringExt, ValueExt}, generate_id, id_type, }; -use diesel_models::{ - business_profile::BusinessProfile, encryption::Encryption, enums as storage_enums, - payment_method, -}; +use diesel_models::{business_profile::BusinessProfile, encryption::Encryption, payment_method}; use domain::CustomerUpdate; use error_stack::{report, ResultExt}; +use euclid::{ + dssa::graph::{AnalysisContext, CgraphExt}, + frontend::dir, +}; +use hyperswitch_constraint_graph as cgraph; +use kgraph_utils::transformers::IntoDirValue; use masking::Secret; use router_env::{instrument, tracing}; use strum::IntoEnumIterator; @@ -45,7 +49,11 @@ use crate::{ configs::settings, core::{ errors::{self, StorageErrorExt}, - payment_methods::{transformers as payment_methods, vault}, + payment_methods::{ + transformers as payment_methods, + utils::{get_merchant_pm_filter_graph, make_pm_graph, refresh_pm_filters_cache}, + vault, + }, payments::{ helpers, routing::{self, SessionFlowRoutingInput}, @@ -1877,31 +1885,94 @@ pub async fn list_payment_methods( .await?; // filter out connectors based on the business country - let filtered_mcas = helpers::filter_mca_based_on_business_profile(all_mcas, profile_id); + let filtered_mcas = + helpers::filter_mca_based_on_business_profile(all_mcas.clone(), profile_id.clone()); logger::debug!(mca_before_filtering=?filtered_mcas); let mut response: Vec = vec![]; - for mca in &filtered_mcas { - let payment_methods = match &mca.payment_methods_enabled { - Some(pm) => pm.clone(), - None => continue, - }; - filter_payment_methods( - payment_methods, - &mut req, - &mut response, - payment_intent.as_ref(), - payment_attempt.as_ref(), - billing_address.as_ref(), - mca.connector_name.clone(), - pm_config_mapping, - &state.conf.mandates.supported_payment_methods, - &state.conf.mandates.update_mandate_supported, - &state.conf.saved_payment_methods, + // Key creation for storing PM_FILTER_CGRAPH + #[cfg(feature = "business_profile_routing")] + let key = { + let profile_id = profile_id + .clone() + .get_required_value("profile_id") + .change_context(errors::ApiErrorResponse::GenericNotFoundError { + message: "Profile id not found".to_string(), + })?; + format!( + "pm_filters_cgraph_{}_{}", + &merchant_account.merchant_id, profile_id ) - .await?; + }; + + #[cfg(not(feature = "business_profile_routing"))] + let key = { format!("pm_filters_cgraph_{}", &merchant_account.merchant_id) }; + + if let Some(graph) = get_merchant_pm_filter_graph(&state, &key).await { + // Derivation of PM_FILTER_CGRAPH from MokaCache successful + for mca in &filtered_mcas { + let payment_methods = match &mca.payment_methods_enabled { + Some(pm) => pm, + None => continue, + }; + filter_payment_methods( + &graph, + payment_methods, + &mut req, + &mut response, + payment_intent.as_ref(), + payment_attempt.as_ref(), + billing_address.as_ref(), + mca.connector_name.clone(), + &state.conf.saved_payment_methods, + ) + .await?; + } + } else { + // No PM_FILTER_CGRAPH Cache present in MokaCache + let mut builder = cgraph::ConstraintGraphBuilder::<'static, _>::new(); + for mca in &filtered_mcas { + let payment_methods = match &mca.payment_methods_enabled { + Some(pm) => pm, + None => continue, + }; + if let Err(e) = make_pm_graph( + &mut builder, + payment_methods, + mca.connector_name.clone(), + pm_config_mapping, + &state.conf.mandates.supported_payment_methods, + &state.conf.mandates.update_mandate_supported, + ) { + logger::error!( + "Failed to construct constraint graph for list payment methods {e:?}" + ); + } + } + + // Refreshing our CGraph cache + let graph = refresh_pm_filters_cache(&state, &key, builder.build()).await; + + for mca in &filtered_mcas { + let payment_methods = match &mca.payment_methods_enabled { + Some(pm) => pm, + None => continue, + }; + filter_payment_methods( + &graph, + payment_methods, + &mut req, + &mut response, + payment_intent.as_ref(), + payment_attempt.as_ref(), + billing_address.as_ref(), + mca.connector_name.clone(), + &state.conf.saved_payment_methods, + ) + .await?; + } } // Filter out wallet payment method from mca if customer has already saved it @@ -2763,20 +2834,18 @@ pub async fn call_surcharge_decision_management_for_saved_card( #[allow(clippy::too_many_arguments)] pub async fn filter_payment_methods( - payment_methods: Vec, + graph: &ConstraintGraph<'_, dir::DirValue>, + payment_methods: &[serde_json::Value], req: &mut api::PaymentMethodListRequest, resp: &mut Vec, payment_intent: Option<&storage::PaymentIntent>, payment_attempt: Option<&storage::PaymentAttempt>, address: Option<&domain::Address>, connector: String, - config: &settings::ConnectorFilters, - supported_payment_methods_for_mandate: &settings::SupportedPaymentMethodsForMandate, - supported_payment_methods_for_update_mandate: &settings::SupportedPaymentMethodsForMandate, saved_payment_methods: &settings::EligiblePaymentMethods, ) -> errors::CustomResult<(), errors::ApiErrorResponse> { - for payment_method in payment_methods.into_iter() { - let parse_result = serde_json::from_value::(payment_method); + for payment_method in payment_methods.iter() { + let parse_result = serde_json::from_value::(payment_method.clone()); if let Ok(payment_methods_enabled) = parse_result { let payment_method = payment_methods_enabled.payment_method; @@ -2807,57 +2876,13 @@ pub async fn filter_payment_methods( .map(|minor_amount| minor_amount.get_amount_as_i64()), ) { - let mut payment_method_object = payment_method_type_info; - - let filter; - ( - payment_method_object.accepted_countries, - req.accepted_countries, - filter, - ) = filter_pm_country_based( - &payment_method_object.accepted_countries, - &req.accepted_countries, - ); - let filter2; - ( - payment_method_object.accepted_currencies, - req.accepted_currencies, - filter2, - ) = filter_pm_currencies_based( - &payment_method_object.accepted_currencies, - &req.accepted_currencies, - ); + let payment_method_object = payment_method_type_info.clone(); - let filter4 = filter_pm_card_network_based( - payment_method_object.card_networks.as_ref(), - req.card_networks.as_ref(), - &payment_method_object.payment_method_type, - ); - - let filter3 = if let Some(payment_intent) = payment_intent { - filter_payment_country_based(&payment_method_object, address).await? - && filter_payment_currency_based(payment_intent, &payment_method_object) - && filter_payment_amount_based(payment_intent, &payment_method_object) - && filter_payment_mandate_based(payment_attempt, &payment_method_object) - .await? - } else { - true - }; - - let filter5 = filter_pm_based_on_config( - config, - &connector, - &payment_method_object.payment_method_type, - payment_attempt, - &mut payment_method_object.card_networks, - &address.and_then(|inner| inner.country), - payment_attempt.and_then(|value| value.currency), - ); - - let filter6 = filter_pm_based_on_allowed_types( - allowed_payment_method_types.as_ref(), - &payment_method_object.payment_method_type, - ); + let pm_dir_value: dir::DirValue = + (payment_method_type_info.payment_method_type, payment_method) + .into_dir_value() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("pm_value_node not created")?; let connector_variant = api_enums::Connector::from_str(connector.as_str()) .change_context(errors::ConnectorError::InvalidConnectorName) @@ -2867,35 +2892,85 @@ pub async fn filter_payment_methods( .attach_printable_lazy(|| { format!("unable to parse connector name {connector:?}") })?; - let filter7 = payment_attempt - .and_then(|attempt| attempt.mandate_details.as_ref()) - .map(|_mandate_details| { - filter_pm_based_on_supported_payments_for_mandate( - supported_payment_methods_for_mandate, - &payment_method, - &payment_method_object.payment_method_type, - connector_variant, - ) + + let mut context_values: Vec = Vec::new(); + context_values.push(pm_dir_value.clone()); + + payment_intent.map(|intent| { + intent.currency.map(|currency| { + context_values.push(dir::DirValue::PaymentCurrency(currency)) }) - .unwrap_or(true); + }); + address.map(|address| { + address.country.map(|country| { + context_values.push(dir::DirValue::BillingCountry( + common_enums::Country::from_alpha2(country), + )) + }) + }); + + // Addition of Connector to context + if let Ok(connector) = api_enums::RoutableConnectors::from_str( + connector_variant.to_string().as_str(), + ) { + context_values.push(dir::DirValue::Connector(Box::new( + api_models::routing::ast::ConnectorChoice { + connector, + #[cfg(not(feature = "connector_choice_mca_id"))] + sub_label: None, + }, + ))); + }; - let filter8 = payment_attempt + let filter_pm_based_on_allowed_types = filter_pm_based_on_allowed_types( + allowed_payment_method_types.as_ref(), + &payment_method_object.payment_method_type, + ); + + if payment_attempt + .and_then(|attempt| attempt.mandate_details.as_ref()) + .is_some() + { + context_values.push(dir::DirValue::PaymentType( + euclid::enums::PaymentType::NewMandate, + )); + }; + + payment_attempt .and_then(|attempt| attempt.mandate_data.as_ref()) .map(|mandate_detail| { if mandate_detail.update_mandate_id.is_some() { - filter_pm_based_on_update_mandate_support_for_connector( - supported_payment_methods_for_update_mandate, - &payment_method, - &payment_method_object.payment_method_type, - connector_variant, - ) - } else { - true + context_values.push(dir::DirValue::PaymentType( + euclid::enums::PaymentType::UpdateMandate, + )); } + }); + + payment_attempt + .map(|attempt| { + attempt.mandate_data.is_none() && attempt.mandate_details.is_none() }) - .unwrap_or(true); + .and_then(|res| { + res.then(|| { + context_values.push(dir::DirValue::PaymentType( + euclid::enums::PaymentType::NonMandate, + )) + }) + }); + + payment_attempt + .and_then(|inner| inner.capture_method) + .map(|capture_method| { + context_values.push(dir::DirValue::CaptureMethod(capture_method)); + }); - let filter9 = req + let filter_pm_card_network_based = filter_pm_card_network_based( + payment_method_object.card_networks.as_ref(), + req.card_networks.as_ref(), + &payment_method_object.payment_method_type, + ); + + let saved_payment_methods_filter = req .client_secret .as_ref() .map(|cs| { @@ -2909,25 +2984,28 @@ pub async fn filter_payment_methods( }) .unwrap_or(true); - let connector = connector.clone(); + let context = AnalysisContext::from_dir_values(context_values.clone()); - let response_pm_type = ResponsePaymentMethodIntermediate::new( - payment_method_object, - connector, - payment_method, + let result = graph.key_value_analysis( + pm_dir_value.clone(), + &context, + &mut cgraph::Memoization::new(), + &mut cgraph::CycleCheck::new(), + None, ); - - if filter - && filter2 - && filter3 - && filter4 - && filter5 - && filter6 - && filter7 - && filter8 - && filter9 + if filter_pm_based_on_allowed_types + && filter_pm_card_network_based + && saved_payment_methods_filter + && matches!(result, Ok(())) { + let response_pm_type = ResponsePaymentMethodIntermediate::new( + payment_method_object, + connector.clone(), + payment_method, + ); resp.push(response_pm_type); + } else { + logger::error!("Filtering Payment Methods Failed"); } } } @@ -2935,287 +3013,12 @@ pub async fn filter_payment_methods( } Ok(()) } -pub fn filter_pm_based_on_update_mandate_support_for_connector( - supported_payment_methods_for_mandate: &settings::SupportedPaymentMethodsForMandate, - payment_method: &api_enums::PaymentMethod, - payment_method_type: &api_enums::PaymentMethodType, - connector: api_enums::Connector, -) -> bool { - if payment_method == &api_enums::PaymentMethod::Card { - supported_payment_methods_for_mandate - .0 - .get(payment_method) - .map(|payment_method_type_hm| { - let pm_credit = payment_method_type_hm - .0 - .get(&api_enums::PaymentMethodType::Credit) - .map(|conn| conn.connector_list.clone()) - .unwrap_or_default(); - let pm_debit = payment_method_type_hm - .0 - .get(&api_enums::PaymentMethodType::Debit) - .map(|conn| conn.connector_list.clone()) - .unwrap_or_default(); - &pm_credit | &pm_debit - }) - .map(|supported_connectors| supported_connectors.contains(&connector)) - .unwrap_or(false) - } else { - supported_payment_methods_for_mandate - .0 - .get(payment_method) - .and_then(|payment_method_type_hm| payment_method_type_hm.0.get(payment_method_type)) - .map(|supported_connectors| supported_connectors.connector_list.contains(&connector)) - .unwrap_or(false) - } -} - -fn filter_pm_based_on_supported_payments_for_mandate( - supported_payment_methods_for_mandate: &settings::SupportedPaymentMethodsForMandate, - payment_method: &api_enums::PaymentMethod, - payment_method_type: &api_enums::PaymentMethodType, - connector: api_enums::Connector, -) -> bool { - supported_payment_methods_for_mandate - .0 - .get(payment_method) - .and_then(|payment_method_type_hm| payment_method_type_hm.0.get(payment_method_type)) - .map(|supported_connectors| supported_connectors.connector_list.contains(&connector)) - .unwrap_or(false) -} -fn filter_pm_based_on_config<'a>( - config: &'a settings::ConnectorFilters, - connector: &'a str, - payment_method_type: &'a api_enums::PaymentMethodType, - payment_attempt: Option<&storage::PaymentAttempt>, - card_network: &mut Option>, - country: &Option, - currency: Option, -) -> bool { - config - .0 - .get(connector) - .or_else(|| config.0.get("default")) - .and_then(|inner| match payment_method_type { - api_enums::PaymentMethodType::Credit | api_enums::PaymentMethodType::Debit => { - let country_currency_filter = inner - .0 - .get(&settings::PaymentMethodFilterKey::PaymentMethodType( - *payment_method_type, - )) - .map(|value| global_country_currency_filter(value, country, currency)); - - card_network_filter(country, currency, card_network, inner); - - let capture_method_filter = payment_attempt - .and_then(|inner| inner.capture_method) - .and_then(|capture_method| { - (capture_method == storage_enums::CaptureMethod::Manual).then(|| { - filter_pm_based_on_capture_method_used(inner, payment_method_type) - }) - }); - - Some( - country_currency_filter.unwrap_or(true) - && capture_method_filter.unwrap_or(true), - ) - } - payment_method_type => inner - .0 - .get(&settings::PaymentMethodFilterKey::PaymentMethodType( - *payment_method_type, - )) - .map(|value| global_country_currency_filter(value, country, currency)), - }) - .unwrap_or(true) -} - -///Filters the payment method list on basis of Capture methods, checks whether the connector issues Manual payments using cards or not if not it won't be visible in payment methods list -fn filter_pm_based_on_capture_method_used( - payment_method_filters: &settings::PaymentMethodFilters, +fn filter_pm_based_on_allowed_types( + allowed_types: Option<&Vec>, payment_method_type: &api_enums::PaymentMethodType, ) -> bool { - payment_method_filters - .0 - .get(&settings::PaymentMethodFilterKey::PaymentMethodType( - *payment_method_type, - )) - .and_then(|v| v.not_available_flows) - .and_then(|v| v.capture_method) - .map(|v| !matches!(v, api_enums::CaptureMethod::Manual)) - .unwrap_or(true) -} - -fn card_network_filter( - country: &Option, - currency: Option, - card_network: &mut Option>, - payment_method_filters: &settings::PaymentMethodFilters, -) { - if let Some(value) = card_network.as_mut() { - let filtered_card_networks = value - .iter() - .filter(|&element| { - let key = settings::PaymentMethodFilterKey::CardNetwork(element.clone()); - payment_method_filters - .0 - .get(&key) - .map(|value| global_country_currency_filter(value, country, currency)) - .unwrap_or(true) - }) - .cloned() - .collect::>(); - *value = filtered_card_networks; - } -} - -fn global_country_currency_filter( - item: &settings::CurrencyCountryFlowFilter, - country: &Option, - currency: Option, -) -> bool { - let country_condition = item - .country - .as_ref() - .zip(country.as_ref()) - .map(|(lhs, rhs)| lhs.contains(rhs)); - let currency_condition = item - .currency - .as_ref() - .zip(currency) - .map(|(lhs, rhs)| lhs.contains(&rhs)); - country_condition.unwrap_or(true) && currency_condition.unwrap_or(true) -} - -fn filter_pm_card_network_based( - pm_card_networks: Option<&Vec>, - request_card_networks: Option<&Vec>, - pm_type: &api_enums::PaymentMethodType, -) -> bool { - match pm_type { - api_enums::PaymentMethodType::Credit | api_enums::PaymentMethodType::Debit => { - match (pm_card_networks, request_card_networks) { - (Some(pm_card_networks), Some(request_card_networks)) => request_card_networks - .iter() - .all(|card_network| pm_card_networks.contains(card_network)), - (None, Some(_)) => false, - _ => true, - } - } - _ => true, - } -} -fn filter_pm_country_based( - accepted_countries: &Option, - req_country_list: &Option>, -) -> ( - Option, - Option>, - bool, -) { - match (accepted_countries, req_country_list) { - (None, None) => (None, None, true), - (None, Some(ref r)) => ( - Some(admin::AcceptedCountries::EnableOnly(r.to_vec())), - Some(r.to_vec()), - true, - ), - (Some(l), None) => (Some(l.to_owned()), None, true), - (Some(l), Some(ref r)) => { - let updated = match l { - admin::AcceptedCountries::EnableOnly(acc) => { - filter_accepted_enum_based(&Some(acc.clone()), &Some(r.to_owned())) - .map(admin::AcceptedCountries::EnableOnly) - } - - admin::AcceptedCountries::DisableOnly(den) => { - filter_disabled_enum_based(&Some(den.clone()), &Some(r.to_owned())) - .map(admin::AcceptedCountries::DisableOnly) - } - - admin::AcceptedCountries::AllAccepted => { - Some(admin::AcceptedCountries::AllAccepted) - } - }; - - (updated, Some(r.to_vec()), true) - } - } -} - -fn filter_pm_currencies_based( - accepted_currency: &Option, - req_currency_list: &Option>, -) -> ( - Option, - Option>, - bool, -) { - match (accepted_currency, req_currency_list) { - (None, None) => (None, None, true), - (None, Some(ref r)) => ( - Some(admin::AcceptedCurrencies::EnableOnly(r.to_vec())), - Some(r.to_vec()), - true, - ), - (Some(l), None) => (Some(l.to_owned()), None, true), - (Some(l), Some(ref r)) => { - let updated = match l { - admin::AcceptedCurrencies::EnableOnly(acc) => { - filter_accepted_enum_based(&Some(acc.clone()), &Some(r.to_owned())) - .map(admin::AcceptedCurrencies::EnableOnly) - } - - admin::AcceptedCurrencies::DisableOnly(den) => { - filter_disabled_enum_based(&Some(den.clone()), &Some(r.to_owned())) - .map(admin::AcceptedCurrencies::DisableOnly) - } - - admin::AcceptedCurrencies::AllAccepted => { - Some(admin::AcceptedCurrencies::AllAccepted) - } - }; - - (updated, Some(r.to_vec()), true) - } - } -} - -fn filter_accepted_enum_based( - left: &Option>, - right: &Option>, -) -> Option> { - match (left, right) { - (Some(ref l), Some(ref r)) => { - let a: HashSet<&T> = HashSet::from_iter(l.iter()); - let b: HashSet<&T> = HashSet::from_iter(r.iter()); - - let y: Vec = a.intersection(&b).map(|&i| i.to_owned()).collect(); - Some(y) - } - (Some(ref l), None) => Some(l.to_vec()), - (_, _) => None, - } -} - -fn filter_disabled_enum_based( - left: &Option>, - right: &Option>, -) -> Option> { - match (left, right) { - (Some(ref l), Some(ref r)) => { - let mut enabled = Vec::new(); - for element in r { - if !l.contains(element) { - enabled.push(element.to_owned()); - } - } - Some(enabled) - } - (None, Some(r)) => Some(r.to_vec()), - (_, _) => None, - } + allowed_types.map_or(true, |pm| pm.contains(payment_method_type)) } fn filter_amount_based(payment_method: &RequestPaymentMethodTypes, amount: Option) -> bool { @@ -3233,24 +3036,9 @@ fn filter_amount_based(payment_method: &RequestPaymentMethodTypes, amount: Optio .map(|max_amt| amt <= max_amt.into()) }) .unwrap_or(true); - // let min_check = match (amount, payment_method.minimum_amount) { - // (Some(amt), Some(min_amt)) => amt >= min_amt, - // (_, _) => true, - // }; - // let max_check = match (amount, payment_method.maximum_amount) { - // (Some(amt), Some(max_amt)) => amt <= max_amt, - // (_, _) => true, - // }; (min_check && max_check) || amount == Some(0) } -fn filter_pm_based_on_allowed_types( - allowed_types: Option<&Vec>, - payment_method_type: &api_enums::PaymentMethodType, -) -> bool { - allowed_types.map_or(true, |pm| pm.contains(payment_method_type)) -} - fn filter_recurring_based( payment_method: &RequestPaymentMethodTypes, recurring_enabled: Option, @@ -3267,54 +3055,23 @@ fn filter_installment_based( }) } -async fn filter_payment_country_based( - pm: &RequestPaymentMethodTypes, - address: Option<&domain::Address>, -) -> errors::CustomResult { - Ok(address.map_or(true, |address| { - address.country.as_ref().map_or(true, |country| { - pm.accepted_countries.as_ref().map_or(true, |ac| match ac { - admin::AcceptedCountries::EnableOnly(acc) => acc.contains(country), - admin::AcceptedCountries::DisableOnly(den) => !den.contains(country), - admin::AcceptedCountries::AllAccepted => true, - }) - }) - })) -} - -fn filter_payment_currency_based( - payment_intent: &storage::PaymentIntent, - pm: &RequestPaymentMethodTypes, -) -> bool { - payment_intent.currency.map_or(true, |currency| { - pm.accepted_currencies.as_ref().map_or(true, |ac| match ac { - admin::AcceptedCurrencies::EnableOnly(acc) => acc.contains(¤cy), - admin::AcceptedCurrencies::DisableOnly(den) => !den.contains(¤cy), - admin::AcceptedCurrencies::AllAccepted => true, - }) - }) -} - -fn filter_payment_amount_based( - payment_intent: &storage::PaymentIntent, - pm: &RequestPaymentMethodTypes, +fn filter_pm_card_network_based( + pm_card_networks: Option<&Vec>, + request_card_networks: Option<&Vec>, + pm_type: &api_enums::PaymentMethodType, ) -> bool { - let amount = payment_intent.amount.get_amount_as_i64(); - (pm.maximum_amount.map_or(true, |amt| amount <= amt.into()) - && pm.minimum_amount.map_or(true, |amt| amount >= amt.into())) - || payment_intent.amount.get_amount_as_i64() == 0 -} - -async fn filter_payment_mandate_based( - payment_attempt: Option<&storage::PaymentAttempt>, - pm: &RequestPaymentMethodTypes, -) -> errors::CustomResult { - let recurring_filter = if !pm.recurring_enabled { - payment_attempt.map_or(true, |pa| pa.mandate_id.is_none()) - } else { - true - }; - Ok(recurring_filter) + match pm_type { + api_enums::PaymentMethodType::Credit | api_enums::PaymentMethodType::Debit => { + match (pm_card_networks, request_card_networks) { + (Some(pm_card_networks), Some(request_card_networks)) => request_card_networks + .iter() + .all(|card_network| pm_card_networks.contains(card_network)), + (None, Some(_)) => false, + _ => true, + } + } + _ => true, + } } pub async fn do_list_customer_pm_fetch_customer_if_not_passed( diff --git a/crates/router/src/core/payment_methods/utils.rs b/crates/router/src/core/payment_methods/utils.rs new file mode 100644 index 000000000000..4a92dbbc8141 --- /dev/null +++ b/crates/router/src/core/payment_methods/utils.rs @@ -0,0 +1,733 @@ +use std::{str::FromStr, sync::Arc}; + +use api_models::{ + admin::{self, PaymentMethodsEnabled}, + enums as api_enums, + payment_methods::RequestPaymentMethodTypes, +}; +use common_enums::enums; +use euclid::frontend::dir; +use hyperswitch_constraint_graph as cgraph; +use kgraph_utils::{error::KgraphError, transformers::IntoDirValue}; +use storage_impl::redis::cache::{CacheKey, PM_FILTERS_CGRAPH_CACHE}; + +use crate::{configs::settings, routes::SessionState}; + +pub fn make_pm_graph( + builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, + payment_methods: &[serde_json::value::Value], + connector: String, + pm_config_mapping: &settings::ConnectorFilters, + supported_payment_methods_for_mandate: &settings::SupportedPaymentMethodsForMandate, + supported_payment_methods_for_update_mandate: &settings::SupportedPaymentMethodsForMandate, +) -> Result<(), KgraphError> { + for payment_method in payment_methods.iter() { + let pm_enabled = serde_json::from_value::(payment_method.clone()); + if let Ok(payment_methods_enabled) = pm_enabled { + compile_pm_graph( + builder, + payment_methods_enabled.clone(), + connector.clone(), + pm_config_mapping, + supported_payment_methods_for_mandate, + supported_payment_methods_for_update_mandate, + )?; + }; + } + Ok(()) +} + +pub async fn get_merchant_pm_filter_graph<'a>( + state: &SessionState, + key: &str, +) -> Option>> { + PM_FILTERS_CGRAPH_CACHE + .get_val::>>( + CacheKey { + key: key.to_string(), + prefix: state.tenant.clone(), + }, + ) + .await +} + +pub async fn refresh_pm_filters_cache( + state: &SessionState, + key: &str, + graph: cgraph::ConstraintGraph<'static, dir::DirValue>, +) -> Arc> { + let pm_filter_graph = Arc::new(graph); + PM_FILTERS_CGRAPH_CACHE + .push( + CacheKey { + key: key.to_string(), + prefix: state.tenant.clone(), + }, + pm_filter_graph.clone(), + ) + .await; + pm_filter_graph +} + +fn compile_pm_graph( + builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, + pm_enabled: PaymentMethodsEnabled, + connector: String, + config: &settings::ConnectorFilters, + supported_payment_methods_for_mandate: &settings::SupportedPaymentMethodsForMandate, + supported_payment_methods_for_update_mandate: &settings::SupportedPaymentMethodsForMandate, +) -> Result<(), KgraphError> { + if let Some(payment_method_types) = pm_enabled.payment_method_types { + for pmt in payment_method_types { + let mut agg_nodes: Vec<(cgraph::NodeId, cgraph::Relation, cgraph::Strength)> = + Vec::new(); + let mut agg_or_nodes_for_mandate_filters: Vec<( + cgraph::NodeId, + cgraph::Relation, + cgraph::Strength, + )> = Vec::new(); + + // Connector supported for Update mandate filter + let res = construct_supported_connectors_for_update_mandate_node( + builder, + supported_payment_methods_for_update_mandate, + pmt.clone(), + &pm_enabled.payment_method, + ); + if let Ok(Some(connector_eligible_for_update_mandates_node)) = res { + agg_or_nodes_for_mandate_filters.push(( + connector_eligible_for_update_mandates_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )) + } + + // Connector supported for mandates filter + if let Some(supported_pm_for_mandates) = supported_payment_methods_for_mandate + .0 + .get(&pm_enabled.payment_method) + { + if let Some(supported_connector_for_mandates) = + supported_pm_for_mandates.0.get(&pmt.payment_method_type) + { + let supported_connectors: Vec = + supported_connector_for_mandates + .connector_list + .clone() + .into_iter() + .collect(); + if let Ok(Some(connector_eligible_for_mandates_node)) = + construct_supported_connectors_for_mandate_node( + builder, + supported_connectors, + ) + { + agg_or_nodes_for_mandate_filters.push(( + connector_eligible_for_mandates_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )) + } + } + } + + // Non Prominent Mandate flows + let payment_type_non_mandate_value_node = builder.make_value_node( + cgraph::NodeValue::Value(dir::DirValue::PaymentType( + euclid::enums::PaymentType::NonMandate, + )), + None, + None::<()>, + ); + let payment_type_setup_mandate_value_node = builder.make_value_node( + cgraph::NodeValue::Value(dir::DirValue::PaymentType( + euclid::enums::PaymentType::SetupMandate, + )), + None, + None::<()>, + ); + + let non_major_mandate_any_node = builder + .make_any_aggregator( + &[ + ( + payment_type_non_mandate_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ( + payment_type_setup_mandate_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ], + None, + None::<()>, + None, + ) + .map_err(KgraphError::GraphConstructionError)?; + + agg_or_nodes_for_mandate_filters.push(( + non_major_mandate_any_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + + let agg_or_node = builder + .make_any_aggregator(&agg_or_nodes_for_mandate_filters, None, None::<()>, None) + .map_err(KgraphError::GraphConstructionError)?; + + agg_nodes.push(( + agg_or_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + + // Capture Method filter + config + .0 + .get(connector.as_str()) + .or_else(|| config.0.get("default")) + .map(|inner| { + if let Ok(Some(capture_method_filter)) = + construct_capture_method_node(builder, inner, &pmt.payment_method_type) + { + agg_nodes.push(( + capture_method_filter, + cgraph::Relation::Negative, + cgraph::Strength::Strong, + )) + } + }); + + // Country filter + if let Ok(Some(country_node)) = compile_accepted_countries_for_mca( + builder, + &pmt.payment_method_type, + pmt.accepted_countries, + config, + connector.clone(), + ) { + agg_nodes.push(( + country_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )) + } + + // Currency filter + if let Ok(Some(currency_node)) = compile_accepted_currency_for_mca( + builder, + &pmt.payment_method_type, + pmt.accepted_currencies, + config, + connector.clone(), + ) { + agg_nodes.push(( + currency_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )) + } + + let and_node_for_all_the_filters = builder + .make_all_aggregator(&agg_nodes, None, None::<()>, None) + .map_err(KgraphError::GraphConstructionError)?; + + // Making our output node + let pmt_info = "PaymentMethodType"; + let dir_node: cgraph::NodeValue = + (pmt.payment_method_type, pm_enabled.payment_method) + .into_dir_value() + .map(Into::into)?; + let payment_method_type_value_node = + builder.make_value_node(dir_node, Some(pmt_info), None::<()>); + + builder + .make_edge( + and_node_for_all_the_filters, + payment_method_type_value_node, + cgraph::Strength::Strong, + cgraph::Relation::Positive, + None::, + ) + .map_err(KgraphError::GraphConstructionError)?; + } + } + Ok(()) +} + +fn construct_capture_method_node( + builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, + payment_method_filters: &settings::PaymentMethodFilters, + payment_method_type: &api_enums::PaymentMethodType, +) -> Result, KgraphError> { + if !payment_method_filters + .0 + .get(&settings::PaymentMethodFilterKey::PaymentMethodType( + *payment_method_type, + )) + .and_then(|v| v.not_available_flows) + .and_then(|v| v.capture_method) + .map(|v| !matches!(v, api_enums::CaptureMethod::Manual)) + .unwrap_or(true) + { + return Ok(Some(builder.make_value_node( + cgraph::NodeValue::Value(dir::DirValue::CaptureMethod( + common_enums::CaptureMethod::Manual, + )), + None, + None::<()>, + ))); + } + Ok(None) +} + +fn construct_supported_connectors_for_update_mandate_node( + builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, + supported_payment_methods_for_update_mandate: &settings::SupportedPaymentMethodsForMandate, + pmt: RequestPaymentMethodTypes, + payment_method: &enums::PaymentMethod, +) -> Result, KgraphError> { + let card_value_node = builder.make_value_node( + cgraph::NodeValue::Value(dir::DirValue::PaymentMethod(enums::PaymentMethod::Card)), + None, + None::<()>, + ); + + let payment_type_value_node = builder.make_value_node( + cgraph::NodeValue::Value(dir::DirValue::PaymentType( + euclid::enums::PaymentType::UpdateMandate, + )), + None, + None::<()>, + ); + + let mut agg_nodes: Vec<(cgraph::NodeId, cgraph::Relation, cgraph::Strength)> = Vec::new(); + let mut card_dir_values = Vec::new(); + let mut non_card_dir_values = Vec::new(); + + if let Some(supported_pm_for_mandates) = supported_payment_methods_for_update_mandate + .0 + .get(payment_method) + { + if payment_method == &enums::PaymentMethod::Card { + if let Some(credit_connector_list) = supported_pm_for_mandates + .0 + .get(&api_enums::PaymentMethodType::Credit) + { + card_dir_values.extend( + credit_connector_list + .connector_list + .clone() + .into_iter() + .filter_map(|connector| { + api_enums::RoutableConnectors::from_str(connector.to_string().as_str()) + .ok() + .map(|connector| { + dir::DirValue::Connector(Box::new( + api_models::routing::ast::ConnectorChoice { + connector, + #[cfg(not(feature = "connector_choice_mca_id"))] + sub_label: None, + }, + )) + }) + }), + ); + } + + if let Some(debit_connector_list) = supported_pm_for_mandates + .0 + .get(&api_enums::PaymentMethodType::Debit) + { + card_dir_values.extend( + debit_connector_list + .connector_list + .clone() + .into_iter() + .filter_map(|connector| { + api_enums::RoutableConnectors::from_str(connector.to_string().as_str()) + .ok() + .map(|connector| { + dir::DirValue::Connector(Box::new( + api_models::routing::ast::ConnectorChoice { + connector, + #[cfg(not(feature = "connector_choice_mca_id"))] + sub_label: None, + }, + )) + }) + }), + ); + } + let card_in_node = builder + .make_in_aggregator(card_dir_values, None, None::<()>) + .map_err(KgraphError::GraphConstructionError)?; + + let card_and_node = builder + .make_all_aggregator( + &[ + ( + card_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ( + payment_type_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ( + card_in_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ], + None, + None::<()>, + None, + ) + .map_err(KgraphError::GraphConstructionError)?; + + agg_nodes.push(( + card_and_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } else if let Some(connector_list) = + supported_pm_for_mandates.0.get(&pmt.payment_method_type) + { + non_card_dir_values.extend( + connector_list + .connector_list + .clone() + .into_iter() + .filter_map(|connector| { + api_enums::RoutableConnectors::from_str(connector.to_string().as_str()) + .ok() + .map(|connector| { + dir::DirValue::Connector(Box::new( + api_models::routing::ast::ConnectorChoice { + connector, + #[cfg(not(feature = "connector_choice_mca_id"))] + sub_label: None, + }, + )) + }) + }), + ); + let non_card_mandate_in_node = builder + .make_in_aggregator(non_card_dir_values, None, None::<()>) + .map_err(KgraphError::GraphConstructionError)?; + + let non_card_and_node = builder + .make_all_aggregator( + &[ + ( + card_value_node, + cgraph::Relation::Negative, + cgraph::Strength::Strong, + ), + ( + payment_type_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ( + non_card_mandate_in_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ], + None, + None::<()>, + None, + ) + .map_err(KgraphError::GraphConstructionError)?; + + agg_nodes.push(( + non_card_and_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } + } + + Ok(Some( + builder + .make_any_aggregator( + &agg_nodes, + Some("any node for card and non card pm"), + None::<()>, + None, + ) + .map_err(KgraphError::GraphConstructionError)?, + )) +} + +fn construct_supported_connectors_for_mandate_node( + builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, + eligible_connectors: Vec, +) -> Result, KgraphError> { + let payment_type_value_node = builder.make_value_node( + cgraph::NodeValue::Value(dir::DirValue::PaymentType( + euclid::enums::PaymentType::NewMandate, + )), + None, + None::<()>, + ); + let connectors_from_config: Vec = eligible_connectors + .into_iter() + .filter_map(|connector| { + match api_enums::RoutableConnectors::from_str(connector.to_string().as_str()) { + Ok(connector) => Some(dir::DirValue::Connector(Box::new( + api_models::routing::ast::ConnectorChoice { + connector, + #[cfg(not(feature = "connector_choice_mca_id"))] + sub_label: None, + }, + ))), + Err(_) => None, + } + }) + .collect(); + + if connectors_from_config.is_empty() { + Ok(None) + } else { + let connector_in_aggregator = builder + .make_in_aggregator(connectors_from_config, None, None::<()>) + .map_err(KgraphError::GraphConstructionError)?; + Ok(Some( + builder + .make_all_aggregator( + &[ + ( + payment_type_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ( + connector_in_aggregator, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + ), + ], + None, + None::<()>, + None, + ) + .map_err(KgraphError::GraphConstructionError)?, + )) + } +} + +// fn construct_card_network_nodes( +// builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, +// mca_card_networks: Vec, +// ) -> Result, KgraphError> { +// Ok(Some( +// builder +// .make_in_aggregator( +// mca_card_networks +// .into_iter() +// .map(dir::DirValue::CardNetwork) +// .collect(), +// None, +// None::<()>, +// ) +// .map_err(KgraphError::GraphConstructionError)?, +// )) +// } + +fn compile_accepted_countries_for_mca( + builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, + payment_method_type: &enums::PaymentMethodType, + pm_countries: Option, + config: &settings::ConnectorFilters, + connector: String, +) -> Result, KgraphError> { + let mut agg_nodes: Vec<(cgraph::NodeId, cgraph::Relation, cgraph::Strength)> = Vec::new(); + + // Country from the MCA + if let Some(pm_obj_countries) = pm_countries { + match pm_obj_countries { + admin::AcceptedCountries::EnableOnly(countries) => { + let pm_object_country_value_node = builder + .make_in_aggregator( + countries + .into_iter() + .map(|country| { + dir::DirValue::BillingCountry(common_enums::Country::from_alpha2( + country, + )) + }) + .collect(), + None, + None::<()>, + ) + .map_err(KgraphError::GraphConstructionError)?; + agg_nodes.push(( + pm_object_country_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } + admin::AcceptedCountries::DisableOnly(countries) => { + let pm_object_country_value_node = builder + .make_in_aggregator( + countries + .into_iter() + .map(|country| { + dir::DirValue::BillingCountry(common_enums::Country::from_alpha2( + country, + )) + }) + .collect(), + None, + None::<()>, + ) + .map_err(KgraphError::GraphConstructionError)?; + agg_nodes.push(( + pm_object_country_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } + admin::AcceptedCountries::AllAccepted => return Ok(None), + } + } + + // country from config + if let Some(config) = config + .0 + .get(connector.as_str()) + .or_else(|| config.0.get("default")) + { + if let Some(value) = config + .0 + .get(&settings::PaymentMethodFilterKey::PaymentMethodType( + *payment_method_type, + )) + { + if let Some(config_countries) = value.country.as_ref() { + let config_countries: Vec = Vec::from_iter(config_countries) + .into_iter() + .map(|country| common_enums::Country::from_alpha2(*country)) + .collect(); + let dir_countries: Vec = config_countries + .into_iter() + .map(dir::DirValue::BillingCountry) + .collect(); + + let config_country_agg_node = builder + .make_in_aggregator(dir_countries, None, None::<()>) + .map_err(KgraphError::GraphConstructionError)?; + + agg_nodes.push(( + config_country_agg_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } + } + } + Ok(Some( + builder + .make_all_aggregator(&agg_nodes, None, None::<()>, None) + .map_err(KgraphError::GraphConstructionError)?, + )) +} + +fn compile_accepted_currency_for_mca( + builder: &mut cgraph::ConstraintGraphBuilder<'_, dir::DirValue>, + payment_method_type: &enums::PaymentMethodType, + pm_currency: Option, + config: &settings::ConnectorFilters, + connector: String, +) -> Result, KgraphError> { + let mut agg_nodes: Vec<(cgraph::NodeId, cgraph::Relation, cgraph::Strength)> = Vec::new(); + // Currency from the MCA + if let Some(pm_obj_currency) = pm_currency { + match pm_obj_currency { + admin::AcceptedCurrencies::EnableOnly(currency) => { + let pm_object_currency_value_node = builder + .make_in_aggregator( + currency + .into_iter() + .map(dir::DirValue::PaymentCurrency) + .collect(), + None, + None::<()>, + ) + .map_err(KgraphError::GraphConstructionError)?; + agg_nodes.push(( + pm_object_currency_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } + admin::AcceptedCurrencies::DisableOnly(currency) => { + let pm_object_currency_value_node = builder + .make_in_aggregator( + currency + .into_iter() + .map(dir::DirValue::PaymentCurrency) + .collect(), + None, + None::<()>, + ) + .map_err(KgraphError::GraphConstructionError)?; + agg_nodes.push(( + pm_object_currency_value_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } + admin::AcceptedCurrencies::AllAccepted => return Ok(None), + } + } + + // country from config + if let Some(config) = config + .0 + .get(connector.as_str()) + .or_else(|| config.0.get("default")) + { + if let Some(value) = config + .0 + .get(&settings::PaymentMethodFilterKey::PaymentMethodType( + *payment_method_type, + )) + { + if let Some(config_currencies) = value.currency.as_ref() { + let config_currency: Vec = + Vec::from_iter(config_currencies) + .into_iter() + .cloned() + .collect(); + + let dir_currencies: Vec = config_currency + .into_iter() + .map(dir::DirValue::PaymentCurrency) + .collect(); + + let config_currency_agg_node = builder + .make_in_aggregator(dir_currencies, None, None::<()>) + .map_err(KgraphError::GraphConstructionError)?; + + agg_nodes.push(( + config_currency_agg_node, + cgraph::Relation::Positive, + cgraph::Strength::Strong, + )); + } + } + } + Ok(Some( + builder + .make_all_aggregator(&agg_nodes, None, None::<()>, None) + .map_err(KgraphError::GraphConstructionError)?, + )) +} diff --git a/crates/router/src/db/merchant_connector_account.rs b/crates/router/src/db/merchant_connector_account.rs index aea37b517be6..0614325089bd 100644 --- a/crates/router/src/db/merchant_connector_account.rs +++ b/crates/router/src/db/merchant_connector_account.rs @@ -430,6 +430,9 @@ impl MerchantConnectorAccountInterface for Store { cache::CacheKind::CGraph( format!("cgraph_{}_{_profile_id}", _merchant_id).into(), ), + cache::CacheKind::PmFiltersCGraph( + format!("pm_filters_cgraph_{}_{_profile_id}", _merchant_id).into(), + ), ], update_call, ) @@ -487,6 +490,9 @@ impl MerchantConnectorAccountInterface for Store { cache::CacheKind::CGraph( format!("cgraph_{}_{_profile_id}", mca.merchant_id).into(), ), + cache::CacheKind::PmFiltersCGraph( + format!("pm_filters_cgraph_{}_{_profile_id}", mca.merchant_id).into(), + ), ], delete_call, ) diff --git a/crates/storage_impl/src/redis/cache.rs b/crates/storage_impl/src/redis/cache.rs index c0375773b544..9d1df7b67eb1 100644 --- a/crates/storage_impl/src/redis/cache.rs +++ b/crates/storage_impl/src/redis/cache.rs @@ -31,6 +31,9 @@ const ROUTING_CACHE_PREFIX: &str = "routing"; /// Prefix for cgraph cache key const CGRAPH_CACHE_PREFIX: &str = "cgraph"; +/// Prefix for PM Filter cgraph cache key +const PM_FILTERS_CGRAPH_CACHE_PREFIX: &str = "pm_filters_cgraph"; + /// Prefix for all kinds of cache key const ALL_CACHE_PREFIX: &str = "all_cache_kind"; @@ -58,6 +61,10 @@ pub static ROUTING_CACHE: Lazy = pub static CGRAPH_CACHE: Lazy = Lazy::new(|| Cache::new(CACHE_TTL, CACHE_TTI, Some(MAX_CAPACITY))); +/// PM Filter CGraph Cache +pub static PM_FILTERS_CGRAPH_CACHE: Lazy = + Lazy::new(|| Cache::new(CACHE_TTL, CACHE_TTI, Some(MAX_CAPACITY))); + /// Trait which defines the behaviour of types that's gonna be stored in Cache pub trait Cacheable: Any + Send + Sync + DynClone { fn as_any(&self) -> &dyn Any; @@ -68,6 +75,7 @@ pub enum CacheKind<'a> { Accounts(Cow<'a, str>), Routing(Cow<'a, str>), CGraph(Cow<'a, str>), + PmFiltersCGraph(Cow<'a, str>), All(Cow<'a, str>), } @@ -78,6 +86,7 @@ impl<'a> From> for RedisValue { CacheKind::Accounts(s) => format!("{ACCOUNTS_CACHE_PREFIX},{s}"), CacheKind::Routing(s) => format!("{ROUTING_CACHE_PREFIX},{s}"), CacheKind::CGraph(s) => format!("{CGRAPH_CACHE_PREFIX},{s}"), + CacheKind::PmFiltersCGraph(s) => format!("{PM_FILTERS_CGRAPH_CACHE_PREFIX},{s}"), CacheKind::All(s) => format!("{ALL_CACHE_PREFIX},{s}"), }; Self::from_string(value) @@ -97,6 +106,9 @@ impl<'a> TryFrom for CacheKind<'a> { CONFIG_CACHE_PREFIX => Ok(Self::Config(Cow::Owned(split.1.to_string()))), ROUTING_CACHE_PREFIX => Ok(Self::Routing(Cow::Owned(split.1.to_string()))), CGRAPH_CACHE_PREFIX => Ok(Self::CGraph(Cow::Owned(split.1.to_string()))), + PM_FILTERS_CGRAPH_CACHE_PREFIX => { + Ok(Self::PmFiltersCGraph(Cow::Owned(split.1.to_string()))) + } ALL_CACHE_PREFIX => Ok(Self::All(Cow::Owned(split.1.to_string()))), _ => Err(validation_err.into()), } diff --git a/crates/storage_impl/src/redis/pub_sub.rs b/crates/storage_impl/src/redis/pub_sub.rs index 1f079784243a..e83546c0f8df 100644 --- a/crates/storage_impl/src/redis/pub_sub.rs +++ b/crates/storage_impl/src/redis/pub_sub.rs @@ -3,7 +3,8 @@ use redis_interface::{errors as redis_errors, PubsubInterface, RedisValue}; use router_env::{logger, tracing::Instrument}; use crate::redis::cache::{ - CacheKey, CacheKind, ACCOUNTS_CACHE, CGRAPH_CACHE, CONFIG_CACHE, ROUTING_CACHE, + CacheKey, CacheKind, ACCOUNTS_CACHE, CGRAPH_CACHE, CONFIG_CACHE, PM_FILTERS_CGRAPH_CACHE, + ROUTING_CACHE, }; #[async_trait::async_trait] @@ -99,6 +100,16 @@ impl PubSubInterface for std::sync::Arc { .await; key } + CacheKind::PmFiltersCGraph(key) => { + PM_FILTERS_CGRAPH_CACHE + .remove(CacheKey { + key: key.to_string(), + prefix: self.key_prefix.clone(), + }) + .await; + + key + } CacheKind::Routing(key) => { ROUTING_CACHE .remove(CacheKey { @@ -127,6 +138,12 @@ impl PubSubInterface for std::sync::Arc { prefix: self.key_prefix.clone(), }) .await; + PM_FILTERS_CGRAPH_CACHE + .remove(CacheKey { + key: key.to_string(), + prefix: self.key_prefix.clone(), + }) + .await; ROUTING_CACHE .remove(CacheKey { key: key.to_string(), From 377d6eacd308aca7048c7af071e0d0f121475888 Mon Sep 17 00:00:00 2001 From: Shankar Singh C <83439957+ShankarSinghC@users.noreply.github.com> Date: Wed, 5 Jun 2024 21:26:08 +0530 Subject: [PATCH 2/9] refactor(business_profile): add `collect_shipping_details_from_wallet_connector` in the business profile response (#4892) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/admin.rs | 7 +++++-- crates/router/src/types/api/admin.rs | 2 ++ openapi/openapi_spec.json | 7 ++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index dc64947bf08c..58cb6fbf3e23 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -936,7 +936,7 @@ pub struct BusinessProfileCreate { /// Whether to use the billing details passed when creating the intent as payment method billing pub use_billing_as_payment_method_billing: Option, - /// A boolean value to indicate if cusomter shipping details needs to be sent for wallets payments + /// A boolean value to indicate if customer shipping details needs to be sent for wallets payments pub collect_shipping_details_from_wallet_connector: Option, } @@ -1013,6 +1013,9 @@ pub struct BusinessProfileResponse { /// Merchant's config to support extended card info feature pub extended_card_info_config: Option, + + /// A boolean value to indicate if customer shipping details needs to be sent for wallets payments + pub collect_shipping_details_from_wallet_connector: Option, } #[derive(Clone, Debug, Deserialize, ToSchema, Serialize)] @@ -1081,7 +1084,7 @@ pub struct BusinessProfileUpdate { // Whether to use the billing details passed when creating the intent as payment method billing pub use_billing_as_payment_method_billing: Option, - /// A boolean value to indicate if cusomter shipping details needs to be sent for wallets payments + /// A boolean value to indicate if customer shipping details needs to be sent for wallets payments pub collect_shipping_details_from_wallet_connector: Option, } diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs index 20083afb1c94..b79819ed6349 100644 --- a/crates/router/src/types/api/admin.rs +++ b/crates/router/src/types/api/admin.rs @@ -85,6 +85,8 @@ impl ForeignTryFrom for BusinessProf .extended_card_info_config .map(|config| config.expose().parse_value("ExtendedCardInfoConfig")) .transpose()?, + collect_shipping_details_from_wallet_connector: item + .collect_shipping_details_from_wallet_connector, }) } } diff --git a/openapi/openapi_spec.json b/openapi/openapi_spec.json index 76cbda10a180..a12df1d916a2 100644 --- a/openapi/openapi_spec.json +++ b/openapi/openapi_spec.json @@ -6824,7 +6824,7 @@ }, "collect_shipping_details_from_wallet_connector": { "type": "boolean", - "description": "A boolean value to indicate if cusomter shipping details needs to be sent for wallets payments", + "description": "A boolean value to indicate if customer shipping details needs to be sent for wallets payments", "nullable": true } }, @@ -6957,6 +6957,11 @@ } ], "nullable": true + }, + "collect_shipping_details_from_wallet_connector": { + "type": "boolean", + "description": "A boolean value to indicate if customer shipping details needs to be sent for wallets payments", + "nullable": true } } }, From 3d53fd018a2b14465bf3cc1557a483e98da07f9b Mon Sep 17 00:00:00 2001 From: AkshayaFoiger <131388445+AkshayaFoiger@users.noreply.github.com> Date: Wed, 5 Jun 2024 23:45:01 +0530 Subject: [PATCH 3/9] refactor(connector): [BOA/CYBS] add customer token for mandates and refactor psync (#4815) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- config/deployments/production.toml | 7 + config/deployments/sandbox.toml | 6 + config/development.toml | 7 + crates/router/src/connector/bankofamerica.rs | 66 ++- .../connector/bankofamerica/transformers.rs | 327 +++++++----- crates/router/src/connector/cybersource.rs | 99 +++- .../src/connector/cybersource/transformers.rs | 468 +++++++++++------- 7 files changed, 649 insertions(+), 331 deletions(-) diff --git a/config/deployments/production.toml b/config/deployments/production.toml index 9b9b6aac2239..355c75a39680 100644 --- a/config/deployments/production.toml +++ b/config/deployments/production.toml @@ -253,6 +253,13 @@ debit = { currency = "USD" } apple_pay = { currency = "USD" } google_pay = { currency = "USD" } + +[pm_filters.cybersource] +credit = { currency = "USD" } +debit = { currency = "USD" } +apple_pay = { currency = "USD" } +google_pay = { currency = "USD" } + [pm_filters.braintree] paypal.currency = "AUD,BRL,CAD,CNY,CZK,DKK,EUR,HKD,HUF,ILS,JPY,MYR,MXN,TWD,NZD,NOK,PHP,PLN,GBP,RUB,SGD,SEK,CHF,THB,USD" diff --git a/config/deployments/sandbox.toml b/config/deployments/sandbox.toml index 891e4b1f1756..e5e44ae7ef96 100644 --- a/config/deployments/sandbox.toml +++ b/config/deployments/sandbox.toml @@ -257,6 +257,12 @@ debit = { currency = "USD" } apple_pay = { currency = "USD" } google_pay = { currency = "USD" } +[pm_filters.cybersource] +credit = { currency = "USD" } +debit = { currency = "USD" } +apple_pay = { currency = "USD" } +google_pay = { currency = "USD" } + [pm_filters.braintree] paypal.currency = "AUD,BRL,CAD,CNY,CZK,DKK,EUR,HKD,HUF,ILS,JPY,MYR,MXN,TWD,NZD,NOK,PHP,PLN,GBP,RUB,SGD,SEK,CHF,THB,USD" diff --git a/config/development.toml b/config/development.toml index a3f9eb54bbf6..8a249754d14c 100644 --- a/config/development.toml +++ b/config/development.toml @@ -395,6 +395,13 @@ debit = { currency = "USD" } apple_pay = { currency = "USD" } google_pay = { currency = "USD" } + +[pm_filters.cybersource] +credit = { currency = "USD" } +debit = { currency = "USD" } +apple_pay = { currency = "USD" } +google_pay = { currency = "USD" } + [pm_filters.braintree] paypal = { currency = "AUD,BRL,CAD,CNY,CZK,DKK,EUR,HKD,HUF,ILS,JPY,MYR,MXN,TWD,NZD,NOK,PHP,PLN,GBP,RUB,SGD,SEK,CHF,THB,USD" } credit = { not_available_flows = { capture_method = "manual" } } diff --git a/crates/router/src/connector/bankofamerica.rs b/crates/router/src/connector/bankofamerica.rs index c95fed8566f3..19bc3f9875bd 100644 --- a/crates/router/src/connector/bankofamerica.rs +++ b/crates/router/src/connector/bankofamerica.rs @@ -211,33 +211,57 @@ impl ConnectorCommon for Bankofamerica { }; match response { transformers::BankOfAmericaErrorResponse::StandardError(response) => { - let (code, connector_reason) = match response.error_information { - Some(ref error_info) => (error_info.reason.clone(), error_info.message.clone()), - None => ( - response - .reason - .map_or(consts::NO_ERROR_CODE.to_string(), |reason| { - reason.to_string() - }), - response - .message - .map_or(error_message.to_string(), |message| message), - ), - }; - let message = match response.details { - Some(details) => details - .iter() - .map(|det| format!("{} : {}", det.field, det.reason)) - .collect::>() - .join(", "), - None => connector_reason.clone(), + let (code, message, reason) = match response.error_information { + Some(ref error_info) => { + let detailed_error_info = error_info.details.as_ref().map(|details| { + details + .iter() + .map(|det| format!("{} : {}", det.field, det.reason)) + .collect::>() + .join(", ") + }); + ( + error_info.reason.clone(), + error_info.reason.clone(), + transformers::get_error_reason( + Some(error_info.message.clone()), + detailed_error_info, + None, + ), + ) + } + None => { + let detailed_error_info = response.details.map(|details| { + details + .iter() + .map(|det| format!("{} : {}", det.field, det.reason)) + .collect::>() + .join(", ") + }); + ( + response + .reason + .clone() + .map_or(consts::NO_ERROR_CODE.to_string(), |reason| { + reason.to_string() + }), + response + .reason + .map_or(error_message.to_string(), |reason| reason.to_string()), + transformers::get_error_reason( + response.message, + detailed_error_info, + None, + ), + ) + } }; Ok(ErrorResponse { status_code: res.status_code, code, message, - reason: Some(connector_reason), + reason, attempt_status: None, connector_transaction_id: None, }) diff --git a/crates/router/src/connector/bankofamerica/transformers.rs b/crates/router/src/connector/bankofamerica/transformers.rs index 90151f12aebf..7446749e73c7 100644 --- a/crates/router/src/connector/bankofamerica/transformers.rs +++ b/crates/router/src/connector/bankofamerica/transformers.rs @@ -106,6 +106,7 @@ pub enum BankOfAmericaActionsList { #[serde(rename_all = "camelCase")] pub enum BankOfAmericaActionsTokenType { PaymentInstrument, + Customer, } #[derive(Debug, Serialize)] @@ -202,11 +203,11 @@ pub struct ApplePayPaymentInformation { #[derive(Debug, Serialize)] #[serde(untagged)] pub enum PaymentInformation { - Cards(CardPaymentInformation), - GooglePay(GooglePayPaymentInformation), - ApplePay(ApplePayPaymentInformation), - ApplePayToken(ApplePayTokenPaymentInformation), - MandatePayment(MandatePaymentInformation), + Cards(Box), + GooglePay(Box), + ApplePay(Box), + ApplePayToken(Box), + MandatePayment(Box), } #[derive(Debug, Serialize)] @@ -422,9 +423,9 @@ impl ..item.data }) } - BankOfAmericaSetupMandatesResponse::ErrorInformation(ref error_response) => { + BankOfAmericaSetupMandatesResponse::ErrorInformation(error_response) => { let response = Err(types::ErrorResponse::foreign_from(( - error_response, + &*error_response, item.http_code, ))); Ok(Self { @@ -640,7 +641,10 @@ impl if is_customer_initiated_mandate_payment(&item.router_data.request) { ( Some(vec![BankOfAmericaActionsList::TokenCreate]), - Some(vec![BankOfAmericaActionsTokenType::PaymentInstrument]), + Some(vec![ + BankOfAmericaActionsTokenType::PaymentInstrument, + BankOfAmericaActionsTokenType::Customer, + ]), Some(BankOfAmericaAuthorizationOptions { initiator: Some(BankOfAmericaPaymentInitiator { initiator_type: Some(BankOfAmericaPaymentInitiatorTypes::Customer), @@ -1237,16 +1241,11 @@ impl let payment_instrument = BankOfAmericaPaymentInstrument { id: connector_mandate_id.into(), }; - let email = item.router_data.request.get_email().ok(); - let bill_to = email.and_then(|email_id| { - item.router_data - .get_billing() - .ok() - .and_then(|billing_details| build_bill_to(billing_details, email_id).ok()) - }); - let order_information = OrderInformationWithBill::from((item, bill_to)); + let order_information = OrderInformationWithBill::from((item, None)); let payment_information = - PaymentInformation::MandatePayment(MandatePaymentInformation { payment_instrument }); + PaymentInformation::MandatePayment(Box::new(MandatePaymentInformation { + payment_instrument, + })); let client_reference_information = ClientReferenceInformation::from(item); let merchant_defined_information = item.router_data.request.metadata.clone().map(|metadata| { @@ -1356,21 +1355,21 @@ pub struct ClientAuthSetupInfoResponse { #[serde(untagged)] pub enum BankOfAmericaAuthSetupResponse { ClientAuthSetupInfo(Box), - ErrorInformation(BankOfAmericaErrorInformationResponse), + ErrorInformation(Box), } #[derive(Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum BankOfAmericaPaymentsResponse { ClientReferenceInformation(Box), - ErrorInformation(BankOfAmericaErrorInformationResponse), + ErrorInformation(Box), } #[derive(Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum BankOfAmericaSetupMandatesResponse { ClientReferenceInformation(Box), - ErrorInformation(BankOfAmericaErrorInformationResponse), + ErrorInformation(Box), } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1539,6 +1538,7 @@ pub struct BankOfAmericaErrorInformationResponse { pub struct BankOfAmericaErrorInformation { reason: Option, message: Option, + details: Option>, } impl @@ -1560,22 +1560,41 @@ impl Option, ), ) -> Self { - let error_reason = error_response - .error_information - .message - .to_owned() - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); - let error_message = error_response.error_information.reason.to_owned(); + let detailed_error_info = + error_response + .error_information + .details + .as_ref() + .map(|details| { + details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") + }); + + let reason = get_error_reason( + error_response.error_information.message.clone(), + detailed_error_info, + None, + ); let response = Err(types::ErrorResponse { - code: error_message + code: error_response + .error_information + .reason .clone() .unwrap_or(consts::NO_ERROR_CODE.to_string()), - message: error_message.unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason), + message: error_response + .error_information + .reason + .clone() + .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), + reason, status_code: item.http_code, attempt_status: None, connector_transaction_id: Some(error_response.id.clone()), }); + match transaction_status { Some(status) => Self { response, @@ -1704,18 +1723,37 @@ impl ..item.data }), BankOfAmericaAuthSetupResponse::ErrorInformation(error_response) => { - let error_reason = error_response - .error_information - .message - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); - let error_message = error_response.error_information.reason; + let detailed_error_info = + error_response + .error_information + .to_owned() + .details + .map(|error_details| { + error_details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") + }); + + let reason = get_error_reason( + error_response.error_information.message, + detailed_error_info, + None, + ); + Ok(Self { response: Err(types::ErrorResponse { - code: error_message + code: error_response + .error_information + .reason .clone() .unwrap_or(consts::NO_ERROR_CODE.to_string()), - message: error_message.unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason), + message: error_response + .error_information + .reason + .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), + reason, status_code: item.http_code, attempt_status: None, connector_transaction_id: Some(error_response.id.clone()), @@ -1767,8 +1805,8 @@ pub struct BankOfAmericaAuthValidateRequest { #[derive(Debug, Serialize)] #[serde(untagged)] pub enum BankOfAmericaPreProcessingRequest { - AuthEnrollment(BankOfAmericaAuthEnrollmentRequest), - AuthValidate(BankOfAmericaAuthValidateRequest), + AuthEnrollment(Box), + AuthValidate(Box), } impl TryFrom<&BankOfAmericaRouterData<&types::PaymentsPreProcessingRouterData>> @@ -1840,16 +1878,21 @@ impl TryFrom<&BankOfAmericaRouterData<&types::PaymentsPreProcessingRouterData>> amount_details, bill_to: Some(bill_to), }; - Ok(Self::AuthEnrollment(BankOfAmericaAuthEnrollmentRequest { - payment_information, - client_reference_information, - consumer_authentication_information: - BankOfAmericaConsumerAuthInformationRequest { - return_url: item.router_data.request.get_complete_authorize_url()?, - reference_id, - }, - order_information, - })) + Ok(Self::AuthEnrollment(Box::new( + BankOfAmericaAuthEnrollmentRequest { + payment_information, + client_reference_information, + consumer_authentication_information: + BankOfAmericaConsumerAuthInformationRequest { + return_url: item + .router_data + .request + .get_complete_authorize_url()?, + reference_id, + }, + order_information, + }, + ))) } Some(_) | None => { let redirect_payload: BankOfAmericaRedirectionAuthResponse = redirect_response @@ -1862,15 +1905,17 @@ impl TryFrom<&BankOfAmericaRouterData<&types::PaymentsPreProcessingRouterData>> .parse_value("BankOfAmericaRedirectionAuthResponse") .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; let order_information = OrderInformation { amount_details }; - Ok(Self::AuthValidate(BankOfAmericaAuthValidateRequest { - payment_information, - client_reference_information, - consumer_authentication_information: - BankOfAmericaConsumerAuthInformationValidateRequest { - authentication_transaction_id: redirect_payload.transaction_id, - }, - order_information, - })) + Ok(Self::AuthValidate(Box::new( + BankOfAmericaAuthValidateRequest { + payment_information, + client_reference_information, + consumer_authentication_information: + BankOfAmericaConsumerAuthInformationValidateRequest { + authentication_transaction_id: redirect_payload.transaction_id, + }, + order_information, + }, + ))) } } } @@ -1960,7 +2005,7 @@ pub struct ClientAuthCheckInfoResponse { #[serde(untagged)] pub enum BankOfAmericaPreProcessingResponse { ClientAuthCheckInfo(Box), - ErrorInformation(BankOfAmericaErrorInformationResponse), + ErrorInformation(Box), } impl From for enums::AttemptStatus { @@ -2061,9 +2106,9 @@ impl }) } } - BankOfAmericaPreProcessingResponse::ErrorInformation(ref error_response) => { + BankOfAmericaPreProcessingResponse::ErrorInformation(error_response) => { let response = Err(types::ErrorResponse::foreign_from(( - error_response, + &*error_response, item.http_code, ))); Ok(Self { @@ -2141,7 +2186,7 @@ impl } BankOfAmericaPaymentsResponse::ErrorInformation(ref error_response) => { Ok(Self::foreign_from(( - &error_response.clone(), + &*error_response.clone(), item, Some(enums::AttemptStatus::Failure), ))) @@ -2214,7 +2259,7 @@ impl } BankOfAmericaPaymentsResponse::ErrorInformation(ref error_response) => { Ok(Self::foreign_from(( - &error_response.clone(), + &*error_response.clone(), item, Some(enums::AttemptStatus::Failure), ))) @@ -2291,7 +2336,7 @@ impl }) } BankOfAmericaPaymentsResponse::ErrorInformation(ref error_response) => { - Ok(Self::foreign_from((&error_response.clone(), item, None))) + Ok(Self::foreign_from((&*error_response.clone(), item, None))) } } } @@ -2328,22 +2373,15 @@ impl }) } BankOfAmericaPaymentsResponse::ErrorInformation(ref error_response) => { - Ok(Self::foreign_from((&error_response.clone(), item, None))) + Ok(Self::foreign_from((&*error_response.clone(), item, None))) } } } } -#[derive(Debug, Deserialize, Serialize)] -#[serde(untagged)] -pub enum BankOfAmericaTransactionResponse { - ApplicationInformation(Box), - ErrorInformation(BankOfAmericaErrorInformationResponse), -} - #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct BankOfAmericaApplicationInfoResponse { +pub struct BankOfAmericaTransactionResponse { id: String, application_information: ApplicationInformation, client_reference_information: Option, @@ -2368,7 +2406,7 @@ pub struct FraudMarkingInformation { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ApplicationInformation { - status: BankofamericaPaymentStatus, + status: Option, } impl @@ -2390,19 +2428,20 @@ impl types::PaymentsResponseData, >, ) -> Result { - match item.response { - BankOfAmericaTransactionResponse::ApplicationInformation(app_response) => { + match item.response.application_information.status { + Some(app_status) => { let status = enums::AttemptStatus::foreign_from(( - app_response.application_information.status, + app_status, item.data.request.is_auto_capture()?, )); let connector_response = match item.data.payment_method { - common_enums::PaymentMethod::Card => app_response + common_enums::PaymentMethod::Card => item + .response .processor_information .as_ref() .and_then(|processor_information| { - app_response + item.response .consumer_authentication_information .as_ref() .map(|consumer_auth_information| { @@ -2430,11 +2469,11 @@ impl if utils::is_payment_failure(status) { Ok(Self { response: Err(types::ErrorResponse::foreign_from(( - &app_response.error_information, + &item.response.error_information, &risk_info, Some(status), item.http_code, - app_response.id.clone(), + item.response.id.clone(), ))), status: enums::AttemptStatus::Failure, connector_response, @@ -2445,16 +2484,17 @@ impl status, response: Ok(types::PaymentsResponseData::TransactionResponse { resource_id: types::ResponseId::ConnectorTransactionId( - app_response.id.clone(), + item.response.id.clone(), ), redirection_data: None, mandate_reference: None, connector_metadata: None, network_txn_id: None, - connector_response_reference_id: app_response + connector_response_reference_id: item + .response .client_reference_information .map(|cref| cref.code) - .unwrap_or(Some(app_response.id)), + .unwrap_or(Some(item.response.id)), incremental_authorization_allowed: None, charge_id: None, }), @@ -2463,17 +2503,17 @@ impl }) } } - BankOfAmericaTransactionResponse::ErrorInformation(error_response) => Ok(Self { + None => Ok(Self { status: item.data.status, response: Ok(types::PaymentsResponseData::TransactionResponse { resource_id: types::ResponseId::ConnectorTransactionId( - error_response.id.clone(), + item.response.id.clone(), ), redirection_data: None, mandate_reference: None, connector_metadata: None, network_txn_id: None, - connector_response_reference_id: Some(error_response.id), + connector_response_reference_id: Some(item.response.id), incremental_authorization_allowed: None, charge_id: None, }), @@ -2704,7 +2744,8 @@ impl TryFrom>, } #[derive(Debug, Default, Deserialize, Serialize)] @@ -2830,13 +2872,24 @@ impl }) }) .unwrap_or(Some("".to_string())); - let error_reason = error_data - .clone() - .map(|error_details| { - error_details.message.unwrap_or("".to_string()) - + &avs_message.unwrap_or("".to_string()) + + let detailed_error_info = error_data.to_owned().and_then(|error_info| { + error_info.details.map(|error_details| { + error_details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") }) - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); + }); + + let reason = get_error_reason( + error_data + .clone() + .and_then(|error_details| error_details.message), + detailed_error_info, + avs_message, + ); let error_message = error_data .clone() .and_then(|error_details| error_details.reason); @@ -2848,7 +2901,7 @@ impl message: error_message .clone() .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason.clone()), + reason, status_code, attempt_status, connector_transaction_id: Some(transaction_id.clone()), @@ -3012,7 +3065,7 @@ impl TryFrom<&domain::Card> for PaymentInformation { Ok(issuer) => Some(String::from(issuer)), Err(_) => None, }; - Ok(Self::Cards(CardPaymentInformation { + Ok(Self::Cards(Box::new(CardPaymentInformation { card: Card { number: ccard.card_number.clone(), expiration_month: ccard.card_exp_month.clone(), @@ -3020,7 +3073,7 @@ impl TryFrom<&domain::Card> for PaymentInformation { security_code: ccard.card_cvc.clone(), card_type, }, - })) + }))) } } @@ -3031,7 +3084,7 @@ impl TryFrom<&Box> for PaymentInformation { let expiration_month = apple_pay_data.get_expiry_month()?; let expiration_year = apple_pay_data.get_four_digit_expiry_year()?; - Ok(Self::ApplePay(ApplePayPaymentInformation { + Ok(Self::ApplePay(Box::new(ApplePayPaymentInformation { tokenized_card: TokenizedCard { number: apple_pay_data.application_primary_account_number.clone(), cryptogram: apple_pay_data @@ -3042,32 +3095,32 @@ impl TryFrom<&Box> for PaymentInformation { expiration_year, expiration_month, }, - })) + }))) } } impl From<&domain::ApplePayWalletData> for PaymentInformation { fn from(apple_pay_data: &domain::ApplePayWalletData) -> Self { - Self::ApplePayToken(ApplePayTokenPaymentInformation { + Self::ApplePayToken(Box::new(ApplePayTokenPaymentInformation { fluid_data: FluidData { value: Secret::from(apple_pay_data.payment_data.clone()), }, tokenized_card: ApplePayTokenizedCard { transaction_type: TransactionType::ApplePay, }, - }) + })) } } impl From<&domain::GooglePayWalletData> for PaymentInformation { fn from(google_pay_data: &domain::GooglePayWalletData) -> Self { - Self::GooglePay(GooglePayPaymentInformation { + Self::GooglePay(Box::new(GooglePayPaymentInformation { fluid_data: FluidData { value: Secret::from( consts::BASE64_ENGINE.encode(google_pay_data.tokenization_data.token.clone()), ), }, - }) + })) } } @@ -3075,18 +3128,36 @@ impl ForeignFrom<(&BankOfAmericaErrorInformationResponse, u16)> for types::Error fn foreign_from( (error_response, status_code): (&BankOfAmericaErrorInformationResponse, u16), ) -> Self { - let error_reason = error_response - .error_information - .message - .to_owned() - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); - let error_message = error_response.error_information.reason.to_owned(); + let detailed_error_info = + error_response + .error_information + .to_owned() + .details + .map(|error_details| { + error_details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") + }); + + let reason = get_error_reason( + error_response.error_information.message.to_owned(), + detailed_error_info, + None, + ); Self { - code: error_message + code: error_response + .error_information + .reason .clone() .unwrap_or(consts::NO_ERROR_CODE.to_string()), - message: error_message.unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason), + message: error_response + .error_information + .reason + .clone() + .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), + reason, status_code, attempt_status: None, connector_transaction_id: Some(error_response.id.clone()), @@ -3101,7 +3172,10 @@ fn get_boa_mandate_action_details() -> ( ) { ( Some(vec![BankOfAmericaActionsList::TokenCreate]), - Some(vec![BankOfAmericaActionsTokenType::PaymentInstrument]), + Some(vec![ + BankOfAmericaActionsTokenType::PaymentInstrument, + BankOfAmericaActionsTokenType::Customer, + ]), Some(BankOfAmericaAuthorizationOptions { initiator: Some(BankOfAmericaPaymentInitiator { initiator_type: Some(BankOfAmericaPaymentInitiatorTypes::Customer), @@ -3137,3 +3211,30 @@ fn is_customer_initiated_mandate_payment(item: &types::CompleteAuthorizeData) -> }) // add check for customer_acceptance } + +pub fn get_error_reason( + error_info: Option, + detailed_error_info: Option, + avs_error_info: Option, +) -> Option { + match (error_info, detailed_error_info, avs_error_info) { + (Some(message), Some(details), Some(avs_message)) => Some(format!( + "{}, detailed_error_information: {}, avs_message: {}", + message, details, avs_message + )), + (Some(message), Some(details), None) => Some(format!( + "{}, detailed_error_information: {}", + message, details + )), + (Some(message), None, Some(avs_message)) => { + Some(format!("{}, avs_message: {}", message, avs_message)) + } + (None, Some(details), Some(avs_message)) => { + Some(format!("{}, avs_message: {}", details, avs_message)) + } + (Some(message), None, None) => Some(message), + (None, Some(details), None) => Some(details), + (None, None, Some(avs_message)) => Some(avs_message), + (None, None, None) => None, + } +} diff --git a/crates/router/src/connector/cybersource.rs b/crates/router/src/connector/cybersource.rs index 3cccb8dcf5c9..1f8de468846b 100644 --- a/crates/router/src/connector/cybersource.rs +++ b/crates/router/src/connector/cybersource.rs @@ -134,33 +134,58 @@ impl ConnectorCommon for Cybersource { Ok(transformers::CybersourceErrorResponse::StandardError(response)) => { event_builder.map(|i| i.set_error_response_body(&response)); router_env::logger::info!(connector_response=?response); - let (code, connector_reason) = match response.error_information { - Some(ref error_info) => (error_info.reason.clone(), error_info.message.clone()), - None => ( - response - .reason - .map_or(consts::NO_ERROR_CODE.to_string(), |reason| { - reason.to_string() - }), - response - .message - .map_or(error_message.to_string(), |message| message), - ), - }; - let message = match response.details { - Some(details) => details - .iter() - .map(|det| format!("{} : {}", det.field, det.reason)) - .collect::>() - .join(", "), - None => connector_reason.clone(), + + let (code, message, reason) = match response.error_information { + Some(ref error_info) => { + let detailed_error_info = error_info.details.as_ref().map(|details| { + details + .iter() + .map(|det| format!("{} : {}", det.field, det.reason)) + .collect::>() + .join(", ") + }); + ( + error_info.reason.clone(), + error_info.reason.clone(), + transformers::get_error_reason( + Some(error_info.message.clone()), + detailed_error_info, + None, + ), + ) + } + None => { + let detailed_error_info = response.details.map(|details| { + details + .iter() + .map(|det| format!("{} : {}", det.field, det.reason)) + .collect::>() + .join(", ") + }); + ( + response + .reason + .clone() + .map_or(consts::NO_ERROR_CODE.to_string(), |reason| { + reason.to_string() + }), + response + .reason + .map_or(error_message.to_string(), |reason| reason.to_string()), + transformers::get_error_reason( + response.message, + detailed_error_info, + None, + ), + ) + } }; Ok(types::ErrorResponse { status_code: res.status_code, code, message, - reason: Some(connector_reason), + reason, attempt_status: None, connector_transaction_id: None, }) @@ -410,6 +435,38 @@ impl ) -> CustomResult { self.build_error_response(res, event_builder) } + + fn get_5xx_error_response( + &self, + res: types::Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + let response: cybersource::CybersourceServerErrorResponse = res + .response + .parse_struct("CybersourceServerErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + + event_builder.map(|event| event.set_response_body(&response)); + router_env::logger::info!(error_response=?response); + + let attempt_status = match response.reason { + Some(reason) => match reason { + transformers::Reason::SystemError => Some(enums::AttemptStatus::Failure), + transformers::Reason::ServerTimeout | transformers::Reason::ServiceTimeout => None, + }, + None => None, + }; + Ok(types::ErrorResponse { + status_code: res.status_code, + reason: response.status.clone(), + code: response.status.unwrap_or(consts::NO_ERROR_CODE.to_string()), + message: response + .message + .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), + attempt_status, + connector_transaction_id: None, + }) + } } impl diff --git a/crates/router/src/connector/cybersource/transformers.rs b/crates/router/src/connector/cybersource/transformers.rs index b800563364ca..3250cb1ba81c 100644 --- a/crates/router/src/connector/cybersource/transformers.rs +++ b/crates/router/src/connector/cybersource/transformers.rs @@ -76,7 +76,10 @@ impl TryFrom<&types::SetupMandateRouterData> for CybersourceZeroMandateRequest { }; let (action_list, action_token_types, authorization_options) = ( Some(vec![CybersourceActionsList::TokenCreate]), - Some(vec![CybersourceActionsTokenType::PaymentInstrument]), + Some(vec![ + CybersourceActionsTokenType::PaymentInstrument, + CybersourceActionsTokenType::Customer, + ]), Some(CybersourceAuthorizationOptions { initiator: Some(CybersourcePaymentInitiator { initiator_type: Some(CybersourcePaymentInitiatorTypes::Customer), @@ -99,7 +102,7 @@ impl TryFrom<&types::SetupMandateRouterData> for CybersourceZeroMandateRequest { Err(_) => None, }; ( - PaymentInformation::Cards(CardPaymentInformation { + PaymentInformation::Cards(Box::new(CardPaymentInformation { card: Card { number: ccard.card_number, expiration_month: ccard.card_exp_month, @@ -107,7 +110,7 @@ impl TryFrom<&types::SetupMandateRouterData> for CybersourceZeroMandateRequest { security_code: Some(ccard.card_cvc), card_type, }, - }), + })), None, ) } @@ -120,17 +123,20 @@ impl TryFrom<&types::SetupMandateRouterData> for CybersourceZeroMandateRequest { let expiration_month = decrypt_data.get_expiry_month()?; let expiration_year = decrypt_data.get_four_digit_expiry_year()?; ( - PaymentInformation::ApplePay(ApplePayPaymentInformation { - tokenized_card: TokenizedCard { - number: decrypt_data.application_primary_account_number, - cryptogram: decrypt_data - .payment_data - .online_payment_cryptogram, - transaction_type: TransactionType::ApplePay, - expiration_year, - expiration_month, + PaymentInformation::ApplePay(Box::new( + ApplePayPaymentInformation { + tokenized_card: TokenizedCard { + number: decrypt_data + .application_primary_account_number, + cryptogram: decrypt_data + .payment_data + .online_payment_cryptogram, + transaction_type: TransactionType::ApplePay, + expiration_year, + expiration_month, + }, }, - }), + )), Some(PaymentSolution::ApplePay), ) } @@ -139,21 +145,23 @@ impl TryFrom<&types::SetupMandateRouterData> for CybersourceZeroMandateRequest { )?, }, None => ( - PaymentInformation::ApplePayToken(ApplePayTokenPaymentInformation { - fluid_data: FluidData { - value: Secret::from(apple_pay_data.payment_data), - descriptor: Some(FLUID_DATA_DESCRIPTOR.to_string()), + PaymentInformation::ApplePayToken(Box::new( + ApplePayTokenPaymentInformation { + fluid_data: FluidData { + value: Secret::from(apple_pay_data.payment_data), + descriptor: Some(FLUID_DATA_DESCRIPTOR.to_string()), + }, + tokenized_card: ApplePayTokenizedCard { + transaction_type: TransactionType::ApplePay, + }, }, - tokenized_card: ApplePayTokenizedCard { - transaction_type: TransactionType::ApplePay, - }, - }), + )), Some(PaymentSolution::ApplePay), ), } } domain::WalletData::GooglePay(google_pay_data) => ( - PaymentInformation::GooglePay(GooglePayPaymentInformation { + PaymentInformation::GooglePay(Box::new(GooglePayPaymentInformation { fluid_data: FluidData { value: Secret::from( consts::BASE64_ENGINE @@ -161,7 +169,7 @@ impl TryFrom<&types::SetupMandateRouterData> for CybersourceZeroMandateRequest { ), descriptor: None, }, - }), + })), Some(PaymentSolution::GooglePay), ), domain::WalletData::AliPayQr(_) @@ -287,6 +295,7 @@ pub enum CybersourceActionsList { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub enum CybersourceActionsTokenType { + Customer, PaymentInstrument, } @@ -389,11 +398,11 @@ pub struct GooglePayPaymentInformation { #[derive(Debug, Serialize)] #[serde(untagged)] pub enum PaymentInformation { - Cards(CardPaymentInformation), - GooglePay(GooglePayPaymentInformation), - ApplePay(ApplePayPaymentInformation), - ApplePayToken(ApplePayTokenPaymentInformation), - MandatePayment(MandatePaymentInformation), + Cards(Box), + GooglePay(Box), + ApplePay(Box), + ApplePayToken(Box), + MandatePayment(Box), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -549,7 +558,10 @@ impl })) { ( Some(vec![CybersourceActionsList::TokenCreate]), - Some(vec![CybersourceActionsTokenType::PaymentInstrument]), + Some(vec![ + CybersourceActionsTokenType::PaymentInstrument, + CybersourceActionsTokenType::Customer, + ]), Some(CybersourceAuthorizationOptions { initiator: Some(CybersourcePaymentInitiator { initiator_type: Some(CybersourcePaymentInitiatorTypes::Customer), @@ -768,7 +780,10 @@ impl { ( Some(vec![CybersourceActionsList::TokenCreate]), - Some(vec![CybersourceActionsTokenType::PaymentInstrument]), + Some(vec![ + CybersourceActionsTokenType::PaymentInstrument, + CybersourceActionsTokenType::Customer, + ]), Some(CybersourceAuthorizationOptions { initiator: Some(CybersourcePaymentInitiator { initiator_type: Some(CybersourcePaymentInitiatorTypes::Customer), @@ -802,13 +817,13 @@ impl impl From<( &CybersourceRouterData<&types::PaymentsAuthorizeRouterData>, - BillTo, + Option, )> for OrderInformationWithBill { fn from( (item, bill_to): ( &CybersourceRouterData<&types::PaymentsAuthorizeRouterData>, - BillTo, + Option, ), ) -> Self { Self { @@ -816,7 +831,7 @@ impl total_amount: item.amount.to_owned(), currency: item.router_data.request.currency, }, - bill_to: Some(bill_to), + bill_to, } } } @@ -900,7 +915,7 @@ impl ) -> Result { let email = item.router_data.request.get_email()?; let bill_to = build_bill_to(item.router_data.get_billing()?, email)?; - let order_information = OrderInformationWithBill::from((item, bill_to)); + let order_information = OrderInformationWithBill::from((item, Some(bill_to))); let card_issuer = ccard.get_card_issuer(); let card_type = match card_issuer { @@ -908,7 +923,7 @@ impl Err(_) => None, }; - let payment_information = PaymentInformation::Cards(CardPaymentInformation { + let payment_information = PaymentInformation::Cards(Box::new(CardPaymentInformation { card: Card { number: ccard.card_number, expiration_month: ccard.card_exp_month, @@ -916,7 +931,7 @@ impl security_code: Some(ccard.card_cvc), card_type: card_type.clone(), }, - }); + })); let processing_information = ProcessingInformation::try_from((item, None, card_type))?; let client_reference_information = ClientReferenceInformation::from(item); @@ -986,7 +1001,7 @@ impl Err(_) => None, }; - let payment_information = PaymentInformation::Cards(CardPaymentInformation { + let payment_information = PaymentInformation::Cards(Box::new(CardPaymentInformation { card: Card { number: ccard.card_number, expiration_month: ccard.card_exp_month, @@ -994,7 +1009,7 @@ impl security_code: Some(ccard.card_cvc), card_type, }, - }); + })); let client_reference_information = ClientReferenceInformation::from(item); let three_ds_info: CybersourceThreeDSMetadata = item @@ -1059,7 +1074,7 @@ impl ) -> Result { let email = item.router_data.request.get_email()?; let bill_to = build_bill_to(item.router_data.get_billing()?, email)?; - let order_information = OrderInformationWithBill::from((item, bill_to)); + let order_information = OrderInformationWithBill::from((item, Some(bill_to))); let processing_information = ProcessingInformation::try_from(( item, Some(PaymentSolution::ApplePay), @@ -1068,15 +1083,16 @@ impl let client_reference_information = ClientReferenceInformation::from(item); let expiration_month = apple_pay_data.get_expiry_month()?; let expiration_year = apple_pay_data.get_four_digit_expiry_year()?; - let payment_information = PaymentInformation::ApplePay(ApplePayPaymentInformation { - tokenized_card: TokenizedCard { - number: apple_pay_data.application_primary_account_number, - cryptogram: apple_pay_data.payment_data.online_payment_cryptogram, - transaction_type: TransactionType::ApplePay, - expiration_year, - expiration_month, - }, - }); + let payment_information = + PaymentInformation::ApplePay(Box::new(ApplePayPaymentInformation { + tokenized_card: TokenizedCard { + number: apple_pay_data.application_primary_account_number, + cryptogram: apple_pay_data.payment_data.online_payment_cryptogram, + transaction_type: TransactionType::ApplePay, + expiration_year, + expiration_month, + }, + })); let merchant_defined_information = item.router_data.request.metadata.clone().map(|metadata| { Vec::::foreign_from(metadata.peek().to_owned()) @@ -1125,16 +1141,17 @@ impl ) -> Result { let email = item.router_data.request.get_email()?; let bill_to = build_bill_to(item.router_data.get_billing()?, email)?; - let order_information = OrderInformationWithBill::from((item, bill_to)); + let order_information = OrderInformationWithBill::from((item, Some(bill_to))); - let payment_information = PaymentInformation::GooglePay(GooglePayPaymentInformation { - fluid_data: FluidData { - value: Secret::from( - consts::BASE64_ENGINE.encode(google_pay_data.tokenization_data.token), - ), - descriptor: None, - }, - }); + let payment_information = + PaymentInformation::GooglePay(Box::new(GooglePayPaymentInformation { + fluid_data: FluidData { + value: Secret::from( + consts::BASE64_ENGINE.encode(google_pay_data.tokenization_data.token), + ), + descriptor: None, + }, + })); let processing_information = ProcessingInformation::try_from((item, Some(PaymentSolution::GooglePay), None))?; let client_reference_information = ClientReferenceInformation::from(item); @@ -1186,7 +1203,7 @@ impl TryFrom<&CybersourceRouterData<&types::PaymentsAuthorizeRouterData>> let bill_to = build_bill_to(item.router_data.get_billing()?, email)?; let order_information = - OrderInformationWithBill::from((item, bill_to)); + OrderInformationWithBill::from((item, Some(bill_to))); let processing_information = ProcessingInformation::try_from(( item, @@ -1196,7 +1213,7 @@ impl TryFrom<&CybersourceRouterData<&types::PaymentsAuthorizeRouterData>> let client_reference_information = ClientReferenceInformation::from(item); let payment_information = PaymentInformation::ApplePayToken( - ApplePayTokenPaymentInformation { + Box::new(ApplePayTokenPaymentInformation { fluid_data: FluidData { value: Secret::from(apple_pay_data.payment_data), descriptor: Some(FLUID_DATA_DESCRIPTOR.to_string()), @@ -1204,7 +1221,7 @@ impl TryFrom<&CybersourceRouterData<&types::PaymentsAuthorizeRouterData>> tokenized_card: ApplePayTokenizedCard { transaction_type: TransactionType::ApplePay, }, - }, + }), ); let merchant_defined_information = item.router_data.request.metadata.clone().map(|metadata| { @@ -1328,11 +1345,11 @@ impl let payment_instrument = CybersoucrePaymentInstrument { id: connector_mandate_id.into(), }; - let email = item.router_data.request.get_email()?; - let bill_to = build_bill_to(item.router_data.get_billing()?, email)?; - let order_information = OrderInformationWithBill::from((item, bill_to)); + let order_information = OrderInformationWithBill::from((item, None)); let payment_information = - PaymentInformation::MandatePayment(MandatePaymentInformation { payment_instrument }); + PaymentInformation::MandatePayment(Box::new(MandatePaymentInformation { + payment_instrument, + })); let client_reference_information = ClientReferenceInformation::from(item); let merchant_defined_information = item.router_data.request.metadata.clone().map(|metadata| { @@ -1370,15 +1387,16 @@ impl TryFrom<&CybersourceRouterData<&types::PaymentsAuthorizeRouterData>> Ok(issuer) => Some(String::from(issuer)), Err(_) => None, }; - let payment_information = PaymentInformation::Cards(CardPaymentInformation { - card: Card { - number: ccard.card_number, - expiration_month: ccard.card_exp_month, - expiration_year: ccard.card_exp_year, - security_code: Some(ccard.card_cvc), - card_type, - }, - }); + let payment_information = + PaymentInformation::Cards(Box::new(CardPaymentInformation { + card: Card { + number: ccard.card_number, + expiration_month: ccard.card_exp_month, + expiration_year: ccard.card_exp_year, + security_code: Some(ccard.card_cvc), + card_type, + }, + })); let client_reference_information = ClientReferenceInformation::from(item); Ok(Self { payment_information, @@ -1664,8 +1682,8 @@ impl From for common_enums::Authoriza #[derive(Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum CybersourcePaymentsResponse { - ClientReferenceInformation(CybersourceClientReferenceResponse), - ErrorInformation(CybersourceErrorInformationResponse), + ClientReferenceInformation(Box), + ErrorInformation(Box), } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1706,8 +1724,8 @@ pub struct ClientAuthSetupInfoResponse { #[derive(Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum CybersourceAuthSetupResponse { - ClientAuthSetupInfo(ClientAuthSetupInfoResponse), - ErrorInformation(CybersourceErrorInformationResponse), + ClientAuthSetupInfo(Box), + ErrorInformation(Box), } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -1720,8 +1738,8 @@ pub struct CybersourcePaymentsIncrementalAuthorizationResponse { #[derive(Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum CybersourceSetupMandatesResponse { - ClientReferenceInformation(CybersourceClientReferenceResponse), - ErrorInformation(CybersourceErrorInformationResponse), + ClientReferenceInformation(Box), + ErrorInformation(Box), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1773,6 +1791,7 @@ pub struct CybersourceTokenInformation { pub struct CybersourceErrorInformation { reason: Option, message: Option, + details: Option>, } impl @@ -1794,18 +1813,36 @@ impl Option, ), ) -> Self { - let error_reason = error_response - .error_information - .message - .to_owned() - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); - let error_message = error_response.error_information.reason.to_owned(); + let detailed_error_info = + error_response + .error_information + .details + .to_owned() + .map(|details| { + details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") + }); + + let reason = get_error_reason( + error_response.error_information.message.clone(), + detailed_error_info, + None, + ); let response = Err(types::ErrorResponse { - code: error_message + code: error_response + .error_information + .reason .clone() .unwrap_or(consts::NO_ERROR_CODE.to_string()), - message: error_message.unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason), + message: error_response + .error_information + .reason + .clone() + .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), + reason, status_code: item.http_code, attempt_status: None, connector_transaction_id: Some(error_response.id.clone()), @@ -1931,7 +1968,7 @@ impl } CybersourcePaymentsResponse::ErrorInformation(ref error_response) => { Ok(Self::foreign_from(( - &error_response.clone(), + &*error_response.clone(), item, Some(enums::AttemptStatus::Failure), ))) @@ -1990,10 +2027,24 @@ impl ..item.data }), CybersourceAuthSetupResponse::ErrorInformation(error_response) => { - let error_reason = error_response - .error_information - .message - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); + let detailed_error_info = + error_response + .error_information + .details + .to_owned() + .map(|details| { + details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") + }); + + let reason = get_error_reason( + error_response.error_information.message, + detailed_error_info, + None, + ); let error_message = error_response.error_information.reason; Ok(Self { response: Err(types::ErrorResponse { @@ -2001,7 +2052,7 @@ impl .clone() .unwrap_or(consts::NO_ERROR_CODE.to_string()), message: error_message.unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason), + reason, status_code: item.http_code, attempt_status: None, connector_transaction_id: Some(error_response.id.clone()), @@ -2053,8 +2104,8 @@ pub struct CybersourceAuthValidateRequest { #[derive(Debug, Serialize)] #[serde(untagged)] pub enum CybersourcePreProcessingRequest { - AuthEnrollment(CybersourceAuthEnrollmentRequest), - AuthValidate(CybersourceAuthValidateRequest), + AuthEnrollment(Box), + AuthValidate(Box), } impl TryFrom<&CybersourceRouterData<&types::PaymentsPreProcessingRouterData>> @@ -2079,15 +2130,17 @@ impl TryFrom<&CybersourceRouterData<&types::PaymentsPreProcessingRouterData>> Ok(issuer) => Some(String::from(issuer)), Err(_) => None, }; - Ok(PaymentInformation::Cards(CardPaymentInformation { - card: Card { - number: ccard.card_number, - expiration_month: ccard.card_exp_month, - expiration_year: ccard.card_exp_year, - security_code: Some(ccard.card_cvc), - card_type, + Ok(PaymentInformation::Cards(Box::new( + CardPaymentInformation { + card: Card { + number: ccard.card_number, + expiration_month: ccard.card_exp_month, + expiration_year: ccard.card_exp_year, + security_code: Some(ccard.card_cvc), + card_type, + }, }, - })) + ))) } domain::PaymentMethodData::Wallet(_) | domain::PaymentMethodData::CardRedirect(_) @@ -2140,16 +2193,21 @@ impl TryFrom<&CybersourceRouterData<&types::PaymentsPreProcessingRouterData>> amount_details, bill_to: Some(bill_to), }; - Ok(Self::AuthEnrollment(CybersourceAuthEnrollmentRequest { - payment_information, - client_reference_information, - consumer_authentication_information: - CybersourceConsumerAuthInformationRequest { - return_url: item.router_data.request.get_complete_authorize_url()?, - reference_id, - }, - order_information, - })) + Ok(Self::AuthEnrollment(Box::new( + CybersourceAuthEnrollmentRequest { + payment_information, + client_reference_information, + consumer_authentication_information: + CybersourceConsumerAuthInformationRequest { + return_url: item + .router_data + .request + .get_complete_authorize_url()?, + reference_id, + }, + order_information, + }, + ))) } Some(_) | None => { let redirect_payload: CybersourceRedirectionAuthResponse = redirect_response @@ -2162,15 +2220,17 @@ impl TryFrom<&CybersourceRouterData<&types::PaymentsPreProcessingRouterData>> .parse_value("CybersourceRedirectionAuthResponse") .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; let order_information = OrderInformation { amount_details }; - Ok(Self::AuthValidate(CybersourceAuthValidateRequest { - payment_information, - client_reference_information, - consumer_authentication_information: - CybersourceConsumerAuthInformationValidateRequest { - authentication_transaction_id: redirect_payload.transaction_id, - }, - order_information, - })) + Ok(Self::AuthValidate(Box::new( + CybersourceAuthValidateRequest { + payment_information, + client_reference_information, + consumer_authentication_information: + CybersourceConsumerAuthInformationValidateRequest { + authentication_transaction_id: redirect_payload.transaction_id, + }, + order_information, + }, + ))) } } } @@ -2260,7 +2320,7 @@ pub struct ClientAuthCheckInfoResponse { #[serde(untagged)] pub enum CybersourcePreProcessingResponse { ClientAuthCheckInfo(Box), - ErrorInformation(CybersourceErrorInformationResponse), + ErrorInformation(Box), } impl From for enums::AttemptStatus { @@ -2360,19 +2420,32 @@ impl }) } } - CybersourcePreProcessingResponse::ErrorInformation(ref error_response) => { - let error_reason = error_response - .error_information - .message - .to_owned() - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); + CybersourcePreProcessingResponse::ErrorInformation(error_response) => { + let detailed_error_info = + error_response + .error_information + .details + .to_owned() + .map(|details| { + details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") + }); + + let reason = get_error_reason( + error_response.error_information.message, + detailed_error_info, + None, + ); let error_message = error_response.error_information.reason.to_owned(); let response = Err(types::ErrorResponse { code: error_message .clone() .unwrap_or(consts::NO_ERROR_CODE.to_string()), message: error_message.unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason), + reason, status_code: item.http_code, attempt_status: None, connector_transaction_id: Some(error_response.id.clone()), @@ -2428,7 +2501,7 @@ impl } CybersourcePaymentsResponse::ErrorInformation(ref error_response) => { Ok(Self::foreign_from(( - &error_response.clone(), + &*error_response.clone(), item, Some(enums::AttemptStatus::Failure), ))) @@ -2481,7 +2554,7 @@ impl }) } CybersourcePaymentsResponse::ErrorInformation(ref error_response) => { - Ok(Self::foreign_from((&error_response.clone(), item, None))) + Ok(Self::foreign_from((&*error_response.clone(), item, None))) } } } @@ -2518,7 +2591,7 @@ impl }) } CybersourcePaymentsResponse::ErrorInformation(ref error_response) => { - Ok(Self::foreign_from((&error_response.clone(), item, None))) + Ok(Self::foreign_from((&*error_response.clone(), item, None))) } } } @@ -2602,19 +2675,32 @@ impl ..item.data }) } - CybersourceSetupMandatesResponse::ErrorInformation(ref error_response) => { - let error_reason = error_response - .error_information - .message - .to_owned() - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); + CybersourceSetupMandatesResponse::ErrorInformation(error_response) => { + let detailed_error_info = + error_response + .error_information + .details + .to_owned() + .map(|details| { + details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", ") + }); + + let reason = get_error_reason( + error_response.error_information.clone().message, + detailed_error_info, + None, + ); let error_message = error_response.error_information.reason.to_owned(); let response = Err(types::ErrorResponse { code: error_message .clone() .unwrap_or(consts::NO_ERROR_CODE.to_string()), message: error_message.unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason), + reason, status_code: item.http_code, attempt_status: None, connector_transaction_id: Some(error_response.id.clone()), @@ -2677,16 +2763,9 @@ impl } } -#[derive(Debug, Deserialize, Serialize)] -#[serde(untagged)] -pub enum CybersourceTransactionResponse { - ApplicationInformation(CybersourceApplicationInfoResponse), - ErrorInformation(CybersourceErrorInformationResponse), -} - #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct CybersourceApplicationInfoResponse { +pub struct CybersourceTransactionResponse { id: String, application_information: ApplicationInformation, client_reference_information: Option, @@ -2696,7 +2775,7 @@ pub struct CybersourceApplicationInfoResponse { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ApplicationInformation { - status: CybersourcePaymentStatus, + status: Option, } impl @@ -2718,10 +2797,10 @@ impl types::PaymentsResponseData, >, ) -> Result { - match item.response { - CybersourceTransactionResponse::ApplicationInformation(app_response) => { + match item.response.application_information.status { + Some(status) => { let status = enums::AttemptStatus::foreign_from(( - app_response.application_information.status, + status, item.data.request.is_auto_capture()?, )); let incremental_authorization_allowed = @@ -2730,11 +2809,11 @@ impl if utils::is_payment_failure(status) { Ok(Self { response: Err(types::ErrorResponse::foreign_from(( - &app_response.error_information, + &item.response.error_information, &risk_info, Some(status), item.http_code, - app_response.id.clone(), + item.response.id.clone(), ))), status: enums::AttemptStatus::Failure, ..item.data @@ -2744,16 +2823,17 @@ impl status, response: Ok(types::PaymentsResponseData::TransactionResponse { resource_id: types::ResponseId::ConnectorTransactionId( - app_response.id.clone(), + item.response.id.clone(), ), redirection_data: None, mandate_reference: None, connector_metadata: None, network_txn_id: None, - connector_response_reference_id: app_response + connector_response_reference_id: item + .response .client_reference_information .map(|cref| cref.code) - .unwrap_or(Some(app_response.id)), + .unwrap_or(Some(item.response.id)), incremental_authorization_allowed, charge_id: None, }), @@ -2761,17 +2841,17 @@ impl }) } } - CybersourceTransactionResponse::ErrorInformation(error_response) => Ok(Self { + None => Ok(Self { status: item.data.status, response: Ok(types::PaymentsResponseData::TransactionResponse { resource_id: types::ResponseId::ConnectorTransactionId( - error_response.id.clone(), + item.response.id.clone(), ), redirection_data: None, mandate_reference: None, connector_metadata: None, network_txn_id: None, - connector_response_reference_id: Some(error_response.id), + connector_response_reference_id: Some(item.response.id), incremental_authorization_allowed: None, charge_id: None, }), @@ -2903,7 +2983,8 @@ impl TryFrom for PaymentInformation { security_code: None, card_type, }; - Ok(Self::Cards(CardPaymentInformation { card })) + Ok(Self::Cards(Box::new(CardPaymentInformation { card }))) } PayoutMethodData::Bank(_) | PayoutMethodData::Wallet(_) => { Err(errors::ConnectorError::NotSupported { @@ -3208,10 +3289,10 @@ pub struct CybersourceAuthenticationErrorResponse { #[derive(Debug, Deserialize, Serialize)] #[serde(untagged)] pub enum CybersourceErrorResponse { - AuthenticationError(CybersourceAuthenticationErrorResponse), + AuthenticationError(Box), //If the request resource is not available/exists in cybersource - NotAvailableError(CybersourceNotAvailableErrorResponse), - StandardError(CybersourceStandardErrorResponse), + NotAvailableError(Box), + StandardError(Box), } #[derive(Debug, Deserialize, Clone, Serialize)] @@ -3225,6 +3306,7 @@ pub struct Details { pub struct ErrorInformation { pub message: String, pub reason: String, + pub details: Option>, } #[derive(Debug, Default, Deserialize, Serialize)] @@ -3262,17 +3344,24 @@ impl }) }) .unwrap_or(Some("".to_string())); - let error_reason = error_data - .clone() - .map(|error_details| { - error_details.message.unwrap_or("".to_string()) - + &avs_message.unwrap_or("".to_string()) - }) - .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()); - let error_message = error_data + + let detailed_error_info = error_data .clone() - .and_then(|error_details| error_details.reason); + .map(|error_data| match error_data.details { + Some(details) => details + .iter() + .map(|details| format!("{} : {}", details.field, details.reason)) + .collect::>() + .join(", "), + None => "".to_string(), + }); + let reason = get_error_reason( + error_data.clone().and_then(|error_info| error_info.message), + detailed_error_info, + avs_message, + ); + let error_message = error_data.clone().and_then(|error_info| error_info.reason); Self { code: error_message .clone() @@ -3280,10 +3369,37 @@ impl message: error_message .clone() .unwrap_or(consts::NO_ERROR_MESSAGE.to_string()), - reason: Some(error_reason.clone()), + reason, status_code, attempt_status, connector_transaction_id: Some(transaction_id.clone()), } } } + +pub fn get_error_reason( + error_info: Option, + detailed_error_info: Option, + avs_error_info: Option, +) -> Option { + match (error_info, detailed_error_info, avs_error_info) { + (Some(message), Some(details), Some(avs_message)) => Some(format!( + "{}, detailed_error_information: {}, avs_message: {}", + message, details, avs_message + )), + (Some(message), Some(details), None) => Some(format!( + "{}, detailed_error_information: {}", + message, details + )), + (Some(message), None, Some(avs_message)) => { + Some(format!("{}, avs_message: {}", message, avs_message)) + } + (None, Some(details), Some(avs_message)) => { + Some(format!("{}, avs_message: {}", details, avs_message)) + } + (Some(message), None, None) => Some(message), + (None, Some(details), None) => Some(details), + (None, None, Some(avs_message)) => Some(avs_message), + (None, None, None) => None, + } +} From dae14139604b52e11f84c1341bfcb2e58c62a884 Mon Sep 17 00:00:00 2001 From: Swangi Kumari <85639103+swangi-kumari@users.noreply.github.com> Date: Thu, 6 Jun 2024 00:34:57 +0530 Subject: [PATCH 4/9] refactor(connector): [KLARNA] Add dynamic fields for klarna payment method (#4891) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/enums.rs | 4 +- crates/router/src/configs/defaults.rs | 133 ++++++++++++++++++++++++++ openapi/openapi_spec.json | 2 +- 3 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index ce039c1156eb..7ee39a33d32a 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -433,7 +433,7 @@ pub enum FieldType { UserFullName, UserEmailAddress, UserPhoneNumber, - UserCountryCode, //phone number's country code + UserPhoneNumberCountryCode, //phone number's country code UserCountry { options: Vec }, //for country inside payment method data ex- bank redirect UserCurrency { options: Vec }, UserCryptoCurrencyNetwork, //for crypto network associated with the cryptopcurrency @@ -494,7 +494,7 @@ impl PartialEq for FieldType { (Self::UserFullName, Self::UserFullName) => true, (Self::UserEmailAddress, Self::UserEmailAddress) => true, (Self::UserPhoneNumber, Self::UserPhoneNumber) => true, - (Self::UserCountryCode, Self::UserCountryCode) => true, + (Self::UserPhoneNumberCountryCode, Self::UserPhoneNumberCountryCode) => true, ( Self::UserCountry { options: options_self, diff --git a/crates/router/src/configs/defaults.rs b/crates/router/src/configs/defaults.rs index 6f1ce4458ea6..1639963188f6 100644 --- a/crates/router/src/configs/defaults.rs +++ b/crates/router/src/configs/defaults.rs @@ -8243,6 +8243,139 @@ impl Default for super::settings::RequiredFields { common : HashMap::new(), } ), + ( + enums::Connector::Klarna, + RequiredFieldFinal { + mandate: HashMap::new(), + non_mandate: HashMap::new(), + common: HashMap::from([ + ( + "shipping.address.first_name".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.first_name".to_string(), + display_name: "shipping_first_name".to_string(), + field_type: enums::FieldType::UserShippingName, + value: None, + } + ), + ( + "shipping.address.last_name".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.last_name".to_string(), + display_name: "shipping_last_name".to_string(), + field_type: enums::FieldType::UserShippingName, + value: None, + } + ), + ( + "shipping.address.city".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.city".to_string(), + display_name: "city".to_string(), + field_type: enums::FieldType::UserShippingAddressCity, + value: None, + } + ), + ( + "shipping.address.line1".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.line1".to_string(), + display_name: "line1".to_string(), + field_type: enums::FieldType::UserShippingAddressLine1, + value: None, + } + ), + ( + "shipping.address.line2".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.line2".to_string(), + display_name: "line2".to_string(), + field_type: enums::FieldType::UserShippingAddressLine2, + value: None, + } + ), + ( + "shipping.address.zip".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.zip".to_string(), + display_name: "zip".to_string(), + field_type: enums::FieldType::UserShippingAddressPincode, + value: None, + } + ), + ( + "shipping.address.state".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.state".to_string(), + display_name: "state".to_string(), + field_type: enums::FieldType::UserShippingAddressState, + value: None, + } + ), + ( + "shipping.email".to_string(), + RequiredFieldInfo { + required_field: "shipping.email".to_string(), + display_name: "email".to_string(), + field_type: enums::FieldType::UserEmailAddress, + value: None, + } + ), + ( + "shipping.phone.number".to_string(), + RequiredFieldInfo { + required_field: "shipping.phone.number".to_string(), + display_name: "phone_number".to_string(), + field_type: enums::FieldType::UserPhoneNumber, + value: None, + } + ), + ( + "shipping.phone.country_code".to_string(), + RequiredFieldInfo { + required_field: "shipping.phone.country_code".to_string(), + display_name: "phone_country_code".to_string(), + field_type: enums::FieldType::UserPhoneNumberCountryCode, + value: None, + } + ), + ( + "shipping.address.country".to_string(), + RequiredFieldInfo { + required_field: "shipping.address.country".to_string(), + display_name: "country".to_string(), + field_type: enums::FieldType::UserShippingAddressCountry{ + options: vec![ + "AU".to_string(), + "AT".to_string(), + "BE".to_string(), + "CA".to_string(), + "CZ".to_string(), + "DK".to_string(), + "FI".to_string(), + "FR".to_string(), + "DE".to_string(), + "GR".to_string(), + "IE".to_string(), + "IT".to_string(), + "NL".to_string(), + "NZ".to_string(), + "NO".to_string(), + "PL".to_string(), + "PT".to_string(), + "ES".to_string(), + "SE".to_string(), + "CH".to_string(), + "GB".to_string(), + "US".to_string(), + ] + }, + value: None, + } + ), + ]), + } + ) ]), }, ), diff --git a/openapi/openapi_spec.json b/openapi/openapi_spec.json index a12df1d916a2..434e6e267810 100644 --- a/openapi/openapi_spec.json +++ b/openapi/openapi_spec.json @@ -9262,7 +9262,7 @@ { "type": "string", "enum": [ - "user_country_code" + "user_phone_number_country_code" ] }, { From ee11723b602111c328769d9990d1790669139226 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Jun 2024 00:14:50 +0000 Subject: [PATCH 5/9] chore(version): 2024.06.06.0 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff445d36849f..254f7d7709a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to HyperSwitch will be documented here. - - - +## 2024.06.06.0 + +### Features + +- **connector:** Add payouts integration for AdyenPlatform ([#4874](https://github.com/juspay/hyperswitch/pull/4874)) ([`32cf06c`](https://github.com/juspay/hyperswitch/commit/32cf06c73611554d263d9bb44d7dbe940d56dd59)) +- **core:** Create Payout Webhook Flow ([#4696](https://github.com/juspay/hyperswitch/pull/4696)) ([`a3183a0`](https://github.com/juspay/hyperswitch/commit/a3183a0c5ba75c9ebf2335b81f7e4ccadd87e7d2)) +- **multitenancy:** Move users and tenants to global schema ([#4781](https://github.com/juspay/hyperswitch/pull/4781)) ([`c5e28f2`](https://github.com/juspay/hyperswitch/commit/c5e28f2670d51bf6529eb729167c97ad301217ef)) + +### Bug Fixes + +- **connector:** + - [ZSL] capture connector transaction ID ([#4863](https://github.com/juspay/hyperswitch/pull/4863)) ([`f39c420`](https://github.com/juspay/hyperswitch/commit/f39c420623bd05147aeb92ed255f84d74e9370cd)) + - [Adyen]add configs for afterpay adyen ([#4885](https://github.com/juspay/hyperswitch/pull/4885)) ([`a8b57ea`](https://github.com/juspay/hyperswitch/commit/a8b57eaf2318d43ee2533622de94123af593c4b6)) +- **users:** Populate correct `org_id` for Internal Signup ([#4888](https://github.com/juspay/hyperswitch/pull/4888)) ([`76ec5e1`](https://github.com/juspay/hyperswitch/commit/76ec5e1e02380efc86cae93923f2a7b2bd0d58a0)) + +### Refactors + +- **business_profile:** Add `collect_shipping_details_from_wallet_connector` in the business profile response ([#4892](https://github.com/juspay/hyperswitch/pull/4892)) ([`377d6ea`](https://github.com/juspay/hyperswitch/commit/377d6eacd308aca7048c7af071e0d0f121475888)) +- **connector:** + - Move AuthorizeSessionToken flow to core from execute_pretasks for nuvei and square ([#4854](https://github.com/juspay/hyperswitch/pull/4854)) ([`32f0fae`](https://github.com/juspay/hyperswitch/commit/32f0fae27de6bd0ab2a8e6de3b93c97205e14151)) + - [BOA/CYBS] add customer token for mandates and refactor psync ([#4815](https://github.com/juspay/hyperswitch/pull/4815)) ([`3d53fd0`](https://github.com/juspay/hyperswitch/commit/3d53fd018a2b14465bf3cc1557a483e98da07f9b)) + - [KLARNA] Add dynamic fields for klarna payment method ([#4891](https://github.com/juspay/hyperswitch/pull/4891)) ([`dae1413`](https://github.com/juspay/hyperswitch/commit/dae14139604b52e11f84c1341bfcb2e58c62a884)) +- **core:** Inclusion of constraint graph for merchant Payment Method list ([#4845](https://github.com/juspay/hyperswitch/pull/4845)) ([`4df84e9`](https://github.com/juspay/hyperswitch/commit/4df84e913f5724491c948c283a022931c617f46f)) + +### Miscellaneous Tasks + +- **eulid_wasm:** Allow merchant to select different paypal paymentmenthod type ([#4882](https://github.com/juspay/hyperswitch/pull/4882)) ([`326b6b5`](https://github.com/juspay/hyperswitch/commit/326b6b52324ae60128a1b1fbcff85ab3b99a500a)) +- **users:** Email templates updated ([#4562](https://github.com/juspay/hyperswitch/pull/4562)) ([`7ab65ac`](https://github.com/juspay/hyperswitch/commit/7ab65ac8834f47c4448b64899ce3e3656132fb63)) + +**Full Changelog:** [`2024.06.05.0...2024.06.06.0`](https://github.com/juspay/hyperswitch/compare/2024.06.05.0...2024.06.06.0) + +- - - + ## 2024.06.05.0 ### Features From 7a9423759e79167c4093c3482ea56f619cf95635 Mon Sep 17 00:00:00 2001 From: Shankar Singh C <83439957+ShankarSinghC@users.noreply.github.com> Date: Thu, 6 Jun 2024 13:53:03 +0530 Subject: [PATCH 6/9] feat(router): add an api to migrate the apple pay certificates from connector metadata to `connector_wallets_details` column in merchant connector account (#4790) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Co-authored-by: Sanchith Hegde <22217505+SanchithHegde@users.noreply.github.com> --- .../src/apple_pay_certificates_migration.rs | 12 ++ crates/api_models/src/events.rs | 1 + .../apple_pay_certificates_migration.rs | 9 + crates/api_models/src/lib.rs | 1 + crates/api_models/src/payments.rs | 17 ++ crates/common_enums/src/enums.rs | 5 - crates/common_utils/src/events.rs | 1 + crates/connector_configs/src/connector.rs | 2 +- .../src/merchant_connector_account.rs | 3 + crates/diesel_models/src/query/generics.rs | 6 +- crates/diesel_models/src/schema.rs | 1 + .../src/payment_method_data.rs | 6 + .../src/router_data.rs | 5 +- crates/openapi/src/openapi.rs | 2 + crates/router/src/core.rs | 1 + crates/router/src/core/admin.rs | 8 +- .../core/apple_pay_certificates_migration.rs | 109 +++++++++++ .../src/core/authentication/transformers.rs | 1 + .../core/fraud_check/flows/checkout_flow.rs | 1 + .../fraud_check/flows/fulfillment_flow.rs | 1 + .../core/fraud_check/flows/record_return.rs | 1 + .../src/core/fraud_check/flows/sale_flow.rs | 1 + .../fraud_check/flows/transaction_flow.rs | 1 + crates/router/src/core/mandate/utils.rs | 1 + crates/router/src/core/payments.rs | 182 ++++++++++-------- .../src/core/payments/flows/session_flow.rs | 16 +- crates/router/src/core/payments/helpers.rs | 104 +++++++--- .../router/src/core/payments/tokenization.rs | 2 +- .../router/src/core/payments/transformers.rs | 2 + crates/router/src/core/utils.rs | 7 + crates/router/src/core/verification/utils.rs | 1 + crates/router/src/core/webhooks/utils.rs | 1 + crates/router/src/db/kafka_store.rs | 11 ++ .../src/db/merchant_connector_account.rs | 130 ++++++++++++- crates/router/src/lib.rs | 1 + crates/router/src/routes.rs | 9 +- crates/router/src/routes/admin.rs | 4 +- crates/router/src/routes/app.rs | 17 +- .../apple_pay_certificates_migration.rs | 30 +++ crates/router/src/routes/lock_utils.rs | 3 + crates/router/src/types.rs | 2 + .../router/src/types/api/verify_connector.rs | 1 + .../domain/merchant_connector_account.rs | 43 ++++- crates/router/src/types/domain/payments.rs | 12 +- crates/router/src/utils.rs | 16 +- crates/router/tests/connectors/aci.rs | 2 + crates/router/tests/connectors/utils.rs | 1 + crates/router_env/src/logger/types.rs | 2 + crates/storage_impl/src/errors.rs | 6 + .../down.sql | 2 + .../up.sql | 2 + openapi/openapi_spec.json | 131 ++++++++++--- 52 files changed, 763 insertions(+), 173 deletions(-) create mode 100644 crates/api_models/src/apple_pay_certificates_migration.rs create mode 100644 crates/api_models/src/events/apple_pay_certificates_migration.rs create mode 100644 crates/router/src/core/apple_pay_certificates_migration.rs create mode 100644 crates/router/src/routes/apple_pay_certificates_migration.rs create mode 100644 migrations/2024-05-28-054439_connector_wallets_details/down.sql create mode 100644 migrations/2024-05-28-054439_connector_wallets_details/up.sql diff --git a/crates/api_models/src/apple_pay_certificates_migration.rs b/crates/api_models/src/apple_pay_certificates_migration.rs new file mode 100644 index 000000000000..796734f53e47 --- /dev/null +++ b/crates/api_models/src/apple_pay_certificates_migration.rs @@ -0,0 +1,12 @@ +#[derive(Debug, Clone, serde::Serialize)] +pub struct ApplePayCertificatesMigrationResponse { + pub migration_successful: Vec, + pub migration_failed: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ApplePayCertificatesMigrationRequest { + pub merchant_ids: Vec, +} + +impl common_utils::events::ApiEventMetric for ApplePayCertificatesMigrationRequest {} diff --git a/crates/api_models/src/events.rs b/crates/api_models/src/events.rs index 9c26576e77b5..078c27e6db9c 100644 --- a/crates/api_models/src/events.rs +++ b/crates/api_models/src/events.rs @@ -1,3 +1,4 @@ +pub mod apple_pay_certificates_migration; pub mod connector_onboarding; pub mod customer; pub mod dispute; diff --git a/crates/api_models/src/events/apple_pay_certificates_migration.rs b/crates/api_models/src/events/apple_pay_certificates_migration.rs new file mode 100644 index 000000000000..f194443bea0e --- /dev/null +++ b/crates/api_models/src/events/apple_pay_certificates_migration.rs @@ -0,0 +1,9 @@ +use common_utils::events::ApiEventMetric; + +use crate::apple_pay_certificates_migration::ApplePayCertificatesMigrationResponse; + +impl ApiEventMetric for ApplePayCertificatesMigrationResponse { + fn get_api_event_type(&self) -> Option { + Some(common_utils::events::ApiEventsType::ApplePayCertificatesMigration) + } +} diff --git a/crates/api_models/src/lib.rs b/crates/api_models/src/lib.rs index a0bc6f6362d8..d6d6deaa2356 100644 --- a/crates/api_models/src/lib.rs +++ b/crates/api_models/src/lib.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod analytics; pub mod api_keys; +pub mod apple_pay_certificates_migration; pub mod blocklist; pub mod cards_info; pub mod conditional_configs; diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index cda13289aa52..8ccfa14eaf63 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -4208,6 +4208,23 @@ pub struct SessionTokenInfo { pub initiative_context: String, #[schema(value_type = Option)] pub merchant_business_country: Option, + #[serde(flatten)] + pub payment_processing_details_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +#[serde(tag = "payment_processing_details_at")] +pub enum PaymentProcessingDetailsAt { + Hyperswitch(PaymentProcessingDetails), + Connector, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, ToSchema)] +pub struct PaymentProcessingDetails { + #[schema(value_type = String)] + pub payment_processing_certificate: Secret, + #[schema(value_type = String)] + pub payment_processing_certificate_key: Secret, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index edb088032cfc..d8859d40fa31 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -2322,11 +2322,6 @@ pub enum ReconStatus { Active, Disabled, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ApplePayFlow { - Simplified, - Manual, -} #[derive( Clone, diff --git a/crates/common_utils/src/events.rs b/crates/common_utils/src/events.rs index 8939e07a76c7..1052840dbc88 100644 --- a/crates/common_utils/src/events.rs +++ b/crates/common_utils/src/events.rs @@ -50,6 +50,7 @@ pub enum ApiEventsType { // TODO: This has to be removed once the corresponding apiEventTypes are created Miscellaneous, RustLocker, + ApplePayCertificatesMigration, FraudCheck, Recon, Dispute { diff --git a/crates/connector_configs/src/connector.rs b/crates/connector_configs/src/connector.rs index b386d5251720..e5f55f88c974 100644 --- a/crates/connector_configs/src/connector.rs +++ b/crates/connector_configs/src/connector.rs @@ -67,7 +67,7 @@ pub enum ConnectorAuthType { #[derive(Debug, Deserialize, serde::Serialize, Clone)] #[serde(untagged)] pub enum ApplePayTomlConfig { - Standard(payments::ApplePayMetadata), + Standard(Box), Zen(ZenApplePay), } diff --git a/crates/diesel_models/src/merchant_connector_account.rs b/crates/diesel_models/src/merchant_connector_account.rs index e45ef0026261..680e3dacc856 100644 --- a/crates/diesel_models/src/merchant_connector_account.rs +++ b/crates/diesel_models/src/merchant_connector_account.rs @@ -43,6 +43,7 @@ pub struct MerchantConnectorAccount { pub applepay_verified_domains: Option>, pub pm_auth_config: Option, pub status: storage_enums::ConnectorStatus, + pub connector_wallets_details: Option, } #[derive(Clone, Debug, Insertable, router_derive::DebugAsDisplay)] @@ -72,6 +73,7 @@ pub struct MerchantConnectorAccountNew { pub applepay_verified_domains: Option>, pub pm_auth_config: Option, pub status: storage_enums::ConnectorStatus, + pub connector_wallets_details: Option, } #[derive(Clone, Debug, AsChangeset, router_derive::DebugAsDisplay)] @@ -96,6 +98,7 @@ pub struct MerchantConnectorAccountUpdateInternal { pub applepay_verified_domains: Option>, pub pm_auth_config: Option, pub status: Option, + pub connector_wallets_details: Option, } impl MerchantConnectorAccountUpdateInternal { diff --git a/crates/diesel_models/src/query/generics.rs b/crates/diesel_models/src/query/generics.rs index 0527ff3a181d..682766679fd7 100644 --- a/crates/diesel_models/src/query/generics.rs +++ b/crates/diesel_models/src/query/generics.rs @@ -166,7 +166,8 @@ where } Err(DieselError::NotFound) => Err(report!(errors::DatabaseError::NotFound)) .attach_printable_lazy(|| format!("Error while updating {debug_values}")), - _ => Err(report!(errors::DatabaseError::Others)) + Err(error) => Err(error) + .change_context(errors::DatabaseError::Others) .attach_printable_lazy(|| format!("Error while updating {debug_values}")), } } @@ -252,7 +253,8 @@ where } Err(DieselError::NotFound) => Err(report!(errors::DatabaseError::NotFound)) .attach_printable_lazy(|| format!("Error while updating by ID {debug_values}")), - _ => Err(report!(errors::DatabaseError::Others)) + Err(error) => Err(error) + .change_context(errors::DatabaseError::Others) .attach_printable_lazy(|| format!("Error while updating by ID {debug_values}")), } } diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index ff5f6aef0e4c..3525730fc8b2 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -679,6 +679,7 @@ diesel::table! { applepay_verified_domains -> Nullable>>, pm_auth_config -> Nullable, status -> ConnectorStatus, + connector_wallets_details -> Nullable, } } diff --git a/crates/hyperswitch_domain_models/src/payment_method_data.rs b/crates/hyperswitch_domain_models/src/payment_method_data.rs index 065290b6b2db..8dca0c86a2ad 100644 --- a/crates/hyperswitch_domain_models/src/payment_method_data.rs +++ b/crates/hyperswitch_domain_models/src/payment_method_data.rs @@ -22,6 +22,12 @@ pub enum PaymentMethodData { CardToken(CardToken), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApplePayFlow { + Simplified(api_models::payments::PaymentProcessingDetails), + Manual, +} + impl PaymentMethodData { pub fn get_payment_method(&self) -> Option { match self { diff --git a/crates/hyperswitch_domain_models/src/router_data.rs b/crates/hyperswitch_domain_models/src/router_data.rs index b5d96a5c53f6..00e13f5fce9d 100644 --- a/crates/hyperswitch_domain_models/src/router_data.rs +++ b/crates/hyperswitch_domain_models/src/router_data.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, marker::PhantomData}; use common_utils::id_type; use masking::Secret; -use crate::payment_address::PaymentAddress; +use crate::{payment_address::PaymentAddress, payment_method_data}; #[derive(Debug, Clone)] pub struct RouterData { @@ -22,6 +22,7 @@ pub struct RouterData { pub address: PaymentAddress, pub auth_type: common_enums::enums::AuthenticationType, pub connector_meta_data: Option, + pub connector_wallets_details: Option, pub amount_captured: Option, pub access_token: Option, pub session_token: Option, @@ -56,7 +57,7 @@ pub struct RouterData { pub connector_http_status_code: Option, pub external_latency: Option, /// Contains apple pay flow type simplified or manual - pub apple_pay_flow: Option, + pub apple_pay_flow: Option, pub frm_metadata: Option, diff --git a/crates/openapi/src/openapi.rs b/crates/openapi/src/openapi.rs index 8016e1411a1a..6ea139aef058 100644 --- a/crates/openapi/src/openapi.rs +++ b/crates/openapi/src/openapi.rs @@ -309,6 +309,8 @@ Never share your secret api keys. Keep them guarded and secure. api_models::payments::FeatureMetadata, api_models::payments::ApplepayConnectorMetadataRequest, api_models::payments::SessionTokenInfo, + api_models::payments::PaymentProcessingDetailsAt, + api_models::payments::PaymentProcessingDetails, api_models::payments::SwishQrData, api_models::payments::AirwallexData, api_models::payments::NoonData, diff --git a/crates/router/src/core.rs b/crates/router/src/core.rs index 02a5873429f6..53787fe04ab9 100644 --- a/crates/router/src/core.rs +++ b/crates/router/src/core.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod api_keys; pub mod api_locking; +pub mod apple_pay_certificates_migration; pub mod authentication; pub mod blocklist; pub mod cache; diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index 0ba807fd68f5..a06dded790f1 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -937,7 +937,7 @@ pub async fn create_payment_connector( payment_methods_enabled, test_mode: req.test_mode, disabled, - metadata: req.metadata, + metadata: req.metadata.clone(), frm_configs, connector_label: Some(connector_label.clone()), business_country: req.business_country, @@ -961,6 +961,7 @@ pub async fn create_payment_connector( applepay_verified_domains: None, pm_auth_config: req.pm_auth_config.clone(), status: connector_status, + connector_wallets_details: helpers::get_encrypted_apple_pay_connector_wallets_details(&key_store, &req.metadata).await?, }; let transaction_type = match req.connector_type { @@ -1200,6 +1201,7 @@ pub async fn update_payment_connector( expected_format: "auth_type and api_key".to_string(), })?; let metadata = req.metadata.clone().or(mca.metadata.clone()); + let connector_name = mca.connector_name.as_ref(); let connector_enum = api_models::enums::Connector::from_str(connector_name) .change_context(errors::ApiErrorResponse::InvalidDataValue { @@ -1275,6 +1277,10 @@ pub async fn update_payment_connector( applepay_verified_domains: None, pm_auth_config: req.pm_auth_config, status: Some(connector_status), + connector_wallets_details: helpers::get_encrypted_apple_pay_connector_wallets_details( + &key_store, &metadata, + ) + .await?, }; // Profile id should always be present diff --git a/crates/router/src/core/apple_pay_certificates_migration.rs b/crates/router/src/core/apple_pay_certificates_migration.rs new file mode 100644 index 000000000000..327358bda504 --- /dev/null +++ b/crates/router/src/core/apple_pay_certificates_migration.rs @@ -0,0 +1,109 @@ +use api_models::apple_pay_certificates_migration; +use common_utils::errors::CustomResult; +use error_stack::ResultExt; +use masking::{PeekInterface, Secret}; + +use super::{ + errors::{self, StorageErrorExt}, + payments::helpers, +}; +use crate::{ + routes::SessionState, + services::{self, logger}, + types::{domain::types as domain_types, storage}, +}; + +pub async fn apple_pay_certificates_migration( + state: SessionState, + req: &apple_pay_certificates_migration::ApplePayCertificatesMigrationRequest, +) -> CustomResult< + services::ApplicationResponse< + apple_pay_certificates_migration::ApplePayCertificatesMigrationResponse, + >, + errors::ApiErrorResponse, +> { + let db = state.store.as_ref(); + + let merchant_id_list = &req.merchant_ids; + + let mut migration_successful_merchant_ids = vec![]; + let mut migration_failed_merchant_ids = vec![]; + + for merchant_id in merchant_id_list { + let key_store = state + .store + .get_merchant_key_store_by_merchant_id( + merchant_id, + &state.store.get_master_key().to_vec().into(), + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError)?; + + let merchant_connector_accounts = db + .find_merchant_connector_account_by_merchant_id_and_disabled_list( + merchant_id, + true, + &key_store, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::InternalServerError)?; + + let mut mca_to_update = vec![]; + + for connector_account in merchant_connector_accounts { + let connector_apple_pay_metadata = + helpers::get_applepay_metadata(connector_account.clone().metadata) + .map_err(|error| { + logger::error!( + "Apple pay metadata parsing failed for {:?} in certificates migrations api {:?}", + connector_account.clone().connector_name, + error + ) + }) + .ok(); + if let Some(apple_pay_metadata) = connector_apple_pay_metadata { + let encrypted_apple_pay_metadata = domain_types::encrypt( + Secret::new( + serde_json::to_value(apple_pay_metadata) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to serialize apple pay metadata as JSON")?, + ), + key_store.key.get_inner().peek(), + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Unable to encrypt connector apple pay metadata")?; + + let updated_mca = + storage::MerchantConnectorAccountUpdate::ConnectorWalletDetailsUpdate { + connector_wallets_details: encrypted_apple_pay_metadata, + }; + + mca_to_update.push((connector_account, updated_mca.into())); + } + } + + let merchant_connector_accounts_update = db + .update_multiple_merchant_connector_accounts(mca_to_update) + .await; + + match merchant_connector_accounts_update { + Ok(_) => { + logger::debug!("Merchant connector accounts updated for merchant id {merchant_id}"); + migration_successful_merchant_ids.push(merchant_id.to_string()); + } + Err(error) => { + logger::debug!( + "Merchant connector accounts update failed with error {error} for merchant id {merchant_id}"); + migration_failed_merchant_ids.push(merchant_id.to_string()); + } + }; + } + + Ok(services::api::ApplicationResponse::Json( + apple_pay_certificates_migration::ApplePayCertificatesMigrationResponse { + migration_successful: migration_successful_merchant_ids, + migration_failed: migration_failed_merchant_ids, + }, + )) +} diff --git a/crates/router/src/core/authentication/transformers.rs b/crates/router/src/core/authentication/transformers.rs index 2255e1ff5820..704784c485ef 100644 --- a/crates/router/src/core/authentication/transformers.rs +++ b/crates/router/src/core/authentication/transformers.rs @@ -153,6 +153,7 @@ pub fn construct_router_data( address, auth_type: common_enums::AuthenticationType::NoThreeDs, connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: None, access_token: None, session_token: None, diff --git a/crates/router/src/core/fraud_check/flows/checkout_flow.rs b/crates/router/src/core/fraud_check/flows/checkout_flow.rs index 74353e83b3d5..679a5dad29a5 100644 --- a/crates/router/src/core/fraud_check/flows/checkout_flow.rs +++ b/crates/router/src/core/fraud_check/flows/checkout_flow.rs @@ -67,6 +67,7 @@ impl ConstructFlowSpecificData( address: PaymentAddress::default(), auth_type: payment_attempt.authentication_type.unwrap_or_default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: payment_intent .amount_captured .map(|amt| amt.get_amount_as_i64()), diff --git a/crates/router/src/core/fraud_check/flows/record_return.rs b/crates/router/src/core/fraud_check/flows/record_return.rs index b8f8810f09b3..ac661231ecc2 100644 --- a/crates/router/src/core/fraud_check/flows/record_return.rs +++ b/crates/router/src/core/fraud_check/flows/record_return.rs @@ -65,6 +65,7 @@ impl ConstructFlowSpecificData { - let domain_data = domain::PaymentMethodData::from(payment_data); - match domain_data { - domain::PaymentMethodData::Wallet(domain::WalletData::ApplePay( - wallet_data, - )) => Some( - ApplePayData::token_json(domain::WalletData::ApplePay(wallet_data)) - .change_context(errors::ApiErrorResponse::InternalServerError)? - .decrypt(state) - .await - .change_context(errors::ApiErrorResponse::InternalServerError)?, - ), - _ => None, + match &tokenization_action { + TokenizationAction::DecryptApplePayToken(payment_processing_details) + | TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt( + payment_processing_details, + ) => { + let apple_pay_data = match payment_data.payment_method_data.clone() { + Some(payment_method_data) => { + let domain_data = domain::PaymentMethodData::from(payment_method_data); + match domain_data { + domain::PaymentMethodData::Wallet(domain::WalletData::ApplePay( + wallet_data, + )) => Some( + ApplePayData::token_json(domain::WalletData::ApplePay(wallet_data)) + .change_context(errors::ApiErrorResponse::InternalServerError)? + .decrypt( + &payment_processing_details.payment_processing_certificate, + &payment_processing_details.payment_processing_certificate_key, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError)?, + ), + _ => None, + } } - } - _ => None, - }; - - let apple_pay_predecrypt = apple_pay_data - .parse_value::( - "ApplePayPredecryptData", - ) - .change_context(errors::ApiErrorResponse::InternalServerError)?; + _ => None, + }; - logger::debug!(?apple_pay_predecrypt); + let apple_pay_predecrypt = apple_pay_data + .parse_value::( + "ApplePayPredecryptData", + ) + .change_context(errors::ApiErrorResponse::InternalServerError)?; - router_data.payment_method_token = Some( - hyperswitch_domain_models::router_data::PaymentMethodToken::ApplePayDecrypt(Box::new( - apple_pay_predecrypt, - )), - ); - } + router_data.payment_method_token = Some( + hyperswitch_domain_models::router_data::PaymentMethodToken::ApplePayDecrypt( + Box::new(apple_pay_predecrypt), + ), + ); + } + _ => (), + }; let pm_token = router_data .add_payment_method_token(state, &connector, &tokenization_action) @@ -2055,7 +2058,7 @@ fn is_payment_method_tokenization_enabled_for_connector( connector_name: &str, payment_method: &storage::enums::PaymentMethod, payment_method_type: &Option, - apple_pay_flow: &Option, + apple_pay_flow: &Option, ) -> RouterResult { let connector_tokenization_filter = state.conf.tokenization.0.get(connector_name); @@ -2080,13 +2083,13 @@ fn is_payment_method_tokenization_enabled_for_connector( fn is_apple_pay_pre_decrypt_type_connector_tokenization( payment_method_type: &Option, - apple_pay_flow: &Option, + apple_pay_flow: &Option, apple_pay_pre_decrypt_flow_filter: Option, ) -> bool { match (payment_method_type, apple_pay_flow) { ( Some(storage::enums::PaymentMethodType::ApplePay), - Some(enums::ApplePayFlow::Simplified), + Some(domain::ApplePayFlow::Simplified(_)), ) => !matches!( apple_pay_pre_decrypt_flow_filter, Some(ApplePayPreDecryptFlow::NetworkTokenization) @@ -2096,18 +2099,22 @@ fn is_apple_pay_pre_decrypt_type_connector_tokenization( } fn decide_apple_pay_flow( + state: &SessionState, payment_method_type: &Option, merchant_connector_account: Option<&helpers::MerchantConnectorAccountType>, -) -> Option { +) -> Option { payment_method_type.and_then(|pmt| match pmt { - enums::PaymentMethodType::ApplePay => check_apple_pay_metadata(merchant_connector_account), + enums::PaymentMethodType::ApplePay => { + check_apple_pay_metadata(state, merchant_connector_account) + } _ => None, }) } fn check_apple_pay_metadata( + state: &SessionState, merchant_connector_account: Option<&helpers::MerchantConnectorAccountType>, -) -> Option { +) -> Option { merchant_connector_account.and_then(|mca| { let metadata = mca.get_metadata(); metadata.and_then(|apple_pay_metadata| { @@ -2141,14 +2148,34 @@ fn check_apple_pay_metadata( apple_pay_combined, ) => match apple_pay_combined { api_models::payments::ApplePayCombinedMetadata::Simplified { .. } => { - enums::ApplePayFlow::Simplified + domain::ApplePayFlow::Simplified(payments_api::PaymentProcessingDetails { + payment_processing_certificate: state + .conf + .applepay_decrypt_keys + .get_inner() + .apple_pay_ppc + .clone(), + payment_processing_certificate_key: state + .conf + .applepay_decrypt_keys + .get_inner() + .apple_pay_ppc_key + .clone(), + }) } - api_models::payments::ApplePayCombinedMetadata::Manual { .. } => { - enums::ApplePayFlow::Manual + api_models::payments::ApplePayCombinedMetadata::Manual { payment_request_data: _, session_token_data } => { + if let Some(manual_payment_processing_details_at) = session_token_data.payment_processing_details_at { + match manual_payment_processing_details_at { + payments_api::PaymentProcessingDetailsAt::Hyperswitch(payment_processing_details) => domain::ApplePayFlow::Simplified(payment_processing_details), + payments_api::PaymentProcessingDetailsAt::Connector => domain::ApplePayFlow::Manual, + } + } else { + domain::ApplePayFlow::Manual + } } }, api_models::payments::ApplepaySessionTokenMetadata::ApplePay(_) => { - enums::ApplePayFlow::Manual + domain::ApplePayFlow::Manual } }) }) @@ -2175,23 +2202,21 @@ async fn decide_payment_method_tokenize_action( payment_method: &storage::enums::PaymentMethod, pm_parent_token: Option<&String>, is_connector_tokenization_enabled: bool, - apple_pay_flow: Option, + apple_pay_flow: Option, ) -> RouterResult { - let is_apple_pay_predecrypt_supported = - matches!(apple_pay_flow, Some(enums::ApplePayFlow::Simplified)); - match pm_parent_token { - None => { - if is_connector_tokenization_enabled && is_apple_pay_predecrypt_supported { - Ok(TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt) - } else if is_connector_tokenization_enabled { - Ok(TokenizationAction::TokenizeInConnectorAndRouter) - } else if is_apple_pay_predecrypt_supported { - Ok(TokenizationAction::DecryptApplePayToken) - } else { - Ok(TokenizationAction::TokenizeInRouter) + None => Ok(match (is_connector_tokenization_enabled, apple_pay_flow) { + (true, Some(domain::ApplePayFlow::Simplified(payment_processing_details))) => { + TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt( + payment_processing_details, + ) } - } + (true, _) => TokenizationAction::TokenizeInConnectorAndRouter, + (false, Some(domain::ApplePayFlow::Simplified(payment_processing_details))) => { + TokenizationAction::DecryptApplePayToken(payment_processing_details) + } + (false, _) => TokenizationAction::TokenizeInRouter, + }), Some(token) => { let redis_conn = state .store @@ -2214,17 +2239,18 @@ async fn decide_payment_method_tokenize_action( match connector_token_option { Some(connector_token) => Ok(TokenizationAction::ConnectorToken(connector_token)), - None => { - if is_connector_tokenization_enabled && is_apple_pay_predecrypt_supported { - Ok(TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt) - } else if is_connector_tokenization_enabled { - Ok(TokenizationAction::TokenizeInConnectorAndRouter) - } else if is_apple_pay_predecrypt_supported { - Ok(TokenizationAction::DecryptApplePayToken) - } else { - Ok(TokenizationAction::TokenizeInRouter) + None => Ok(match (is_connector_tokenization_enabled, apple_pay_flow) { + (true, Some(domain::ApplePayFlow::Simplified(payment_processing_details))) => { + TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt( + payment_processing_details, + ) } - } + (true, _) => TokenizationAction::TokenizeInConnectorAndRouter, + (false, Some(domain::ApplePayFlow::Simplified(payment_processing_details))) => { + TokenizationAction::DecryptApplePayToken(payment_processing_details) + } + (false, _) => TokenizationAction::TokenizeInRouter, + }), } } } @@ -2237,8 +2263,8 @@ pub enum TokenizationAction { TokenizeInConnectorAndRouter, ConnectorToken(String), SkipConnectorTokenization, - DecryptApplePayToken, - TokenizeInConnectorAndApplepayPreDecrypt, + DecryptApplePayToken(payments_api::PaymentProcessingDetails), + TokenizeInConnectorAndApplepayPreDecrypt(payments_api::PaymentProcessingDetails), } #[allow(clippy::too_many_arguments)] @@ -2279,7 +2305,7 @@ where let payment_method_type = &payment_data.payment_attempt.payment_method_type; let apple_pay_flow = - decide_apple_pay_flow(payment_method_type, Some(merchant_connector_account)); + decide_apple_pay_flow(state, payment_method_type, Some(merchant_connector_account)); let is_connector_tokenization_enabled = is_payment_method_tokenization_enabled_for_connector( @@ -2348,12 +2374,14 @@ where TokenizationAction::SkipConnectorTokenization => { TokenizationAction::SkipConnectorTokenization } - TokenizationAction::DecryptApplePayToken => { - TokenizationAction::DecryptApplePayToken - } - TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt => { - TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt + TokenizationAction::DecryptApplePayToken(payment_processing_details) => { + TokenizationAction::DecryptApplePayToken(payment_processing_details) } + TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt( + payment_processing_details, + ) => TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt( + payment_processing_details, + ), }; (payment_data.to_owned(), connector_tokenization_action) } diff --git a/crates/router/src/core/payments/flows/session_flow.rs b/crates/router/src/core/payments/flows/session_flow.rs index 76441f40759d..14a3b9327dc0 100644 --- a/crates/router/src/core/payments/flows/session_flow.rs +++ b/crates/router/src/core/payments/flows/session_flow.rs @@ -166,8 +166,20 @@ async fn create_applepay_session_token( ) } else { // Get the apple pay metadata - let apple_pay_metadata = - helpers::get_applepay_metadata(router_data.connector_meta_data.clone())?; + let connector_apple_pay_wallet_details = + helpers::get_applepay_metadata(router_data.connector_wallets_details.clone()) + .map_err(|error| { + logger::debug!( + "Apple pay connector wallets details parsing failed in create_applepay_session_token {:?}", + error + ) + }) + .ok(); + + let apple_pay_metadata = match connector_apple_pay_wallet_details { + Some(apple_pay_wallet_details) => apple_pay_wallet_details, + None => helpers::get_applepay_metadata(router_data.connector_meta_data.clone())?, + }; // Get payment request data , apple pay session request and merchant keys let ( diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index d11d0ef3c847..3a9e1e4b457a 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -6,6 +6,7 @@ use api_models::{ }; use base64::Engine; use common_utils::{ + crypto::Encryptable, ext_traits::{AsyncExt, ByteSliceExt, Encode, ValueExt}, fp_utils, generate_id, id_type, pii, types::MinorUnit, @@ -3169,6 +3170,13 @@ impl MerchantConnectorAccountType { } } + pub fn get_connector_wallets_details(&self) -> Option> { + match self { + Self::DbVal(val) => val.connector_wallets_details.as_deref().cloned(), + Self::CacheVal(_) => None, + } + } + pub fn is_disabled(&self) -> bool { match self { Self::DbVal(ref inner) => inner.disabled.unwrap_or(false), @@ -3368,6 +3376,7 @@ pub fn router_data_type_conversion( refund_id: router_data.refund_id, dispute_id: router_data.dispute_id, connector_response: router_data.connector_response, + connector_wallets_details: router_data.connector_wallets_details, } } @@ -3905,17 +3914,32 @@ pub fn validate_customer_access( pub fn is_apple_pay_simplified_flow( connector_metadata: Option, + connector_wallets_details: Option, connector_name: Option<&String>, ) -> CustomResult { - let option_apple_pay_metadata = get_applepay_metadata(connector_metadata) - .map_err(|error| { - logger::info!( - "Apple pay metadata parsing for {:?} in is_apple_pay_simplified_flow {:?}", + let connector_apple_pay_wallet_details = + get_applepay_metadata(connector_wallets_details) + .map_err(|error| { + logger::debug!( + "Apple pay connector wallets details parsing failed for {:?} in is_apple_pay_simplified_flow {:?}", + connector_name, + error + ) + }) + .ok(); + + let option_apple_pay_metadata = match connector_apple_pay_wallet_details { + Some(apple_pay_wallet_details) => Some(apple_pay_wallet_details), + None => get_applepay_metadata(connector_metadata) + .map_err(|error| { + logger::debug!( + "Apple pay metadata parsing failed for {:?} in is_apple_pay_simplified_flow {:?}", connector_name, error ) - }) - .ok(); + }) + .ok(), + }; // return true only if the apple flow type is simplified Ok(matches!( @@ -3928,6 +3952,38 @@ pub fn is_apple_pay_simplified_flow( )) } +pub async fn get_encrypted_apple_pay_connector_wallets_details( + key_store: &domain::MerchantKeyStore, + connector_metadata: &Option>, +) -> RouterResult>>> { + let apple_pay_metadata = get_applepay_metadata(connector_metadata.clone()) + .map_err(|error| { + logger::error!( + "Apple pay metadata parsing failed in get_encrypted_apple_pay_connector_wallets_details {:?}", + error + ) + }) + .ok(); + + let connector_apple_pay_details = apple_pay_metadata + .map(|metadata| { + serde_json::to_value(metadata) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to serialize apple pay metadata as JSON") + }) + .transpose()? + .map(masking::Secret::new); + + let encrypted_connector_apple_pay_details = connector_apple_pay_details + .async_lift(|wallets_details| { + types::encrypt_optional(wallets_details, key_store.key.get_inner().peek()) + }) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while encrypting connector wallets details")?; + Ok(encrypted_connector_apple_pay_details) +} + pub fn get_applepay_metadata( connector_metadata: Option, ) -> RouterResult { @@ -3991,6 +4047,7 @@ where let connector_data_list = if is_apple_pay_simplified_flow( merchant_connector_account_type.get_metadata(), + merchant_connector_account_type.get_connector_wallets_details(), merchant_connector_account_type .get_connector_name() .as_ref(), @@ -4010,6 +4067,10 @@ where for merchant_connector_account in merchant_connector_account_list { if is_apple_pay_simplified_flow( merchant_connector_account.metadata, + merchant_connector_account + .connector_wallets_details + .as_deref() + .cloned(), Some(&merchant_connector_account.connector_name), )? { let connector_data = api::ConnectorData::get_connector_by_name( @@ -4064,10 +4125,11 @@ impl ApplePayData { pub async fn decrypt( &self, - state: &SessionState, + payment_processing_certificate: &masking::Secret, + payment_processing_certificate_key: &masking::Secret, ) -> CustomResult { - let merchant_id = self.merchant_id(state).await?; - let shared_secret = self.shared_secret(state).await?; + let merchant_id = self.merchant_id(payment_processing_certificate)?; + let shared_secret = self.shared_secret(payment_processing_certificate_key)?; let symmetric_key = self.symmetric_key(&merchant_id, &shared_secret)?; let decrypted = self.decrypt_ciphertext(&symmetric_key)?; let parsed_decrypted: serde_json::Value = serde_json::from_str(&decrypted) @@ -4075,17 +4137,11 @@ impl ApplePayData { Ok(parsed_decrypted) } - pub async fn merchant_id( + pub fn merchant_id( &self, - state: &SessionState, + payment_processing_certificate: &masking::Secret, ) -> CustomResult { - let cert_data = state - .conf - .applepay_decrypt_keys - .get_inner() - .apple_pay_ppc - .clone() - .expose(); + let cert_data = payment_processing_certificate.clone().expose(); let base64_decode_cert_data = BASE64_ENGINE .decode(cert_data) @@ -4120,9 +4176,9 @@ impl ApplePayData { Ok(apple_pay_m_id) } - pub async fn shared_secret( + pub fn shared_secret( &self, - state: &SessionState, + payment_processing_certificate_key: &masking::Secret, ) -> CustomResult, errors::ApplePayDecryptionError> { let public_ec_bytes = BASE64_ENGINE .decode(self.header.ephemeral_public_key.peek().as_bytes()) @@ -4132,13 +4188,7 @@ impl ApplePayData { .change_context(errors::ApplePayDecryptionError::KeyDeserializationFailed) .attach_printable("Failed to deserialize the public key")?; - let decrypted_apple_pay_ppc_key = state - .conf - .applepay_decrypt_keys - .get_inner() - .apple_pay_ppc_key - .clone() - .expose(); + let decrypted_apple_pay_ppc_key = payment_processing_certificate_key.clone().expose(); // Create PKey objects from EcKey let private_key = PKey::private_key_from_pem(decrypted_apple_pay_ppc_key.as_bytes()) diff --git a/crates/router/src/core/payments/tokenization.rs b/crates/router/src/core/payments/tokenization.rs index 48637a0d2b32..7016c7542be7 100644 --- a/crates/router/src/core/payments/tokenization.rs +++ b/crates/router/src/core/payments/tokenization.rs @@ -727,7 +727,7 @@ pub async fn add_payment_method_token( ) -> RouterResult> { match tokenization_action { payments::TokenizationAction::TokenizeInConnector - | payments::TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt => { + | payments::TokenizationAction::TokenizeInConnectorAndApplepayPreDecrypt(_) => { let connector_integration: services::BoxedConnectorIntegration< '_, api::PaymentMethodToken, diff --git a/crates/router/src/core/payments/transformers.rs b/crates/router/src/core/payments/transformers.rs index fa1d68ca2357..959724696ab6 100644 --- a/crates/router/src/core/payments/transformers.rs +++ b/crates/router/src/core/payments/transformers.rs @@ -125,6 +125,7 @@ where }; let apple_pay_flow = payments::decide_apple_pay_flow( + state, &payment_data.payment_attempt.payment_method_type, Some(merchant_connector_account), ); @@ -154,6 +155,7 @@ where .authentication_type .unwrap_or_default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), request: T::try_from(additional_data)?, response, amount_captured: payment_data diff --git a/crates/router/src/core/utils.rs b/crates/router/src/core/utils.rs index 1071e5c98d4e..3c4a119b9c1f 100644 --- a/crates/router/src/core/utils.rs +++ b/crates/router/src/core/utils.rs @@ -164,6 +164,7 @@ pub async fn construct_payout_router_data<'a, F>( address, auth_type: enums::AuthenticationType::default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: None, payment_method_status: None, request: types::PayoutsData { @@ -316,6 +317,7 @@ pub async fn construct_refund_router_data<'a, F>( address: PaymentAddress::default(), auth_type: payment_attempt.authentication_type.unwrap_or_default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: payment_intent .amount_captured .map(|amt| amt.get_amount_as_i64()), @@ -565,6 +567,7 @@ pub async fn construct_accept_dispute_router_data<'a>( address: PaymentAddress::default(), auth_type: payment_attempt.authentication_type.unwrap_or_default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: payment_intent .amount_captured .map(|amt| amt.get_amount_as_i64()), @@ -661,6 +664,7 @@ pub async fn construct_submit_evidence_router_data<'a>( address: PaymentAddress::default(), auth_type: payment_attempt.authentication_type.unwrap_or_default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: payment_intent .amount_captured .map(|amt| amt.get_amount_as_i64()), @@ -755,6 +759,7 @@ pub async fn construct_upload_file_router_data<'a>( address: PaymentAddress::default(), auth_type: payment_attempt.authentication_type.unwrap_or_default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: payment_intent .amount_captured .map(|amt| amt.get_amount_as_i64()), @@ -853,6 +858,7 @@ pub async fn construct_defend_dispute_router_data<'a>( address: PaymentAddress::default(), auth_type: payment_attempt.authentication_type.unwrap_or_default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: payment_intent .amount_captured .map(|amt| amt.get_amount_as_i64()), @@ -942,6 +948,7 @@ pub async fn construct_retrieve_file_router_data<'a>( address: PaymentAddress::default(), auth_type: diesel_models::enums::AuthenticationType::default(), connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), amount_captured: None, payment_method_status: None, request: types::RetrieveFileRequestData { diff --git a/crates/router/src/core/verification/utils.rs b/crates/router/src/core/verification/utils.rs index 7518091ee871..bfbc1cb8b448 100644 --- a/crates/router/src/core/verification/utils.rs +++ b/crates/router/src/core/verification/utils.rs @@ -61,6 +61,7 @@ pub async fn check_existence_and_add_domain_to_db( pm_auth_config: None, connector_label: None, status: None, + connector_wallets_details: None, }; state .store diff --git a/crates/router/src/core/webhooks/utils.rs b/crates/router/src/core/webhooks/utils.rs index ad1aafd31257..1394c68cfa8a 100644 --- a/crates/router/src/core/webhooks/utils.rs +++ b/crates/router/src/core/webhooks/utils.rs @@ -86,6 +86,7 @@ pub async fn construct_webhook_router_data<'a>( address: PaymentAddress::default(), auth_type: diesel_models::enums::AuthenticationType::default(), connector_meta_data: None, + connector_wallets_details: None, amount_captured: None, request: types::VerifyWebhookSourceRequestData { webhook_headers: request_details.headers.clone(), diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index ea4a4180bc19..c360b41a6cf1 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -942,6 +942,17 @@ impl FileMetadataInterface for KafkaStore { #[async_trait::async_trait] impl MerchantConnectorAccountInterface for KafkaStore { + async fn update_multiple_merchant_connector_accounts( + &self, + merchant_connector_accounts: Vec<( + domain::MerchantConnectorAccount, + storage::MerchantConnectorAccountUpdateInternal, + )>, + ) -> CustomResult<(), errors::StorageError> { + self.diesel_store + .update_multiple_merchant_connector_accounts(merchant_connector_accounts) + .await + } async fn find_merchant_connector_account_by_merchant_id_connector_label( &self, merchant_id: &str, diff --git a/crates/router/src/db/merchant_connector_account.rs b/crates/router/src/db/merchant_connector_account.rs index 0614325089bd..a176db265495 100644 --- a/crates/router/src/db/merchant_connector_account.rs +++ b/crates/router/src/db/merchant_connector_account.rs @@ -1,4 +1,6 @@ +use async_bb8_diesel::AsyncConnection; use common_utils::ext_traits::{AsyncExt, ByteSliceExt, Encode}; +use diesel_models::encryption::Encryption; use error_stack::{report, ResultExt}; use router_env::{instrument, tracing}; #[cfg(feature = "accounts_cache")] @@ -165,6 +167,14 @@ where key_store: &domain::MerchantKeyStore, ) -> CustomResult; + async fn update_multiple_merchant_connector_accounts( + &self, + this: Vec<( + domain::MerchantConnectorAccount, + storage::MerchantConnectorAccountUpdateInternal, + )>, + ) -> CustomResult<(), errors::StorageError>; + async fn delete_merchant_connector_account_by_merchant_id_merchant_connector_id( &self, merchant_id: &str, @@ -381,6 +391,104 @@ impl MerchantConnectorAccountInterface for Store { .await } + #[instrument(skip_all)] + async fn update_multiple_merchant_connector_accounts( + &self, + merchant_connector_accounts: Vec<( + domain::MerchantConnectorAccount, + storage::MerchantConnectorAccountUpdateInternal, + )>, + ) -> CustomResult<(), errors::StorageError> { + let conn = connection::pg_connection_write(self).await?; + + async fn update_call( + connection: &diesel_models::PgPooledConn, + (merchant_connector_account, mca_update): ( + domain::MerchantConnectorAccount, + storage::MerchantConnectorAccountUpdateInternal, + ), + ) -> Result<(), error_stack::Report> { + Conversion::convert(merchant_connector_account) + .await + .change_context(errors::StorageError::EncryptionError)? + .update(connection, mca_update) + .await + .map_err(|error| report!(errors::StorageError::from(error)))?; + Ok(()) + } + + conn.transaction_async(|connection_pool| async move { + for (merchant_connector_account, update_merchant_connector_account) in + merchant_connector_accounts + { + let _connector_name = merchant_connector_account.connector_name.clone(); + let _profile_id = merchant_connector_account.profile_id.clone().ok_or( + errors::StorageError::ValueNotFound("profile_id".to_string()), + )?; + + let _merchant_id = merchant_connector_account.merchant_id.clone(); + let _merchant_connector_id = + merchant_connector_account.merchant_connector_id.clone(); + + let update = update_call( + &connection_pool, + ( + merchant_connector_account, + update_merchant_connector_account, + ), + ); + + #[cfg(feature = "accounts_cache")] + // Redact all caches as any of might be used because of backwards compatibility + cache::publish_and_redact_multiple( + self, + [ + cache::CacheKind::Accounts( + format!("{}_{}", _profile_id, _connector_name).into(), + ), + cache::CacheKind::Accounts( + format!("{}_{}", _merchant_id, _merchant_connector_id).into(), + ), + cache::CacheKind::CGraph( + format!("cgraph_{}_{_profile_id}", _merchant_id).into(), + ), + ], + || update, + ) + .await + .map_err(|error| { + // Returning `DatabaseConnectionError` after logging the actual error because + // -> it is not possible to get the underlying from `error_stack::Report` + // -> it is not possible to write a `From` impl to convert the `diesel::result::Error` to `error_stack::Report` + // because of Rust's orphan rules + router_env::logger::error!( + ?error, + "DB transaction for updating multiple merchant connector account failed" + ); + errors::StorageError::DatabaseConnectionError + })?; + + #[cfg(not(feature = "accounts_cache"))] + { + update.await.map_err(|error| { + // Returning `DatabaseConnectionError` after logging the actual error because + // -> it is not possible to get the underlying from `error_stack::Report` + // -> it is not possible to write a `From` impl to convert the `diesel::result::Error` to `error_stack::Report` + // because of Rust's orphan rules + router_env::logger::error!( + ?error, + "DB transaction for updating multiple merchant connector account failed" + ); + errors::StorageError::DatabaseConnectionError + })?; + } + } + Ok::<_, errors::StorageError>(()) + }) + .await?; + Ok(()) + } + #[instrument(skip_all)] async fn update_merchant_connector_account( &self, @@ -417,7 +525,7 @@ impl MerchantConnectorAccountInterface for Store { #[cfg(feature = "accounts_cache")] { - // Redact both the caches as any one or both might be used because of backwards compatibility + // Redact all caches as any of might be used because of backwards compatibility cache::publish_and_redact_multiple( self, [ @@ -508,6 +616,17 @@ impl MerchantConnectorAccountInterface for Store { #[async_trait::async_trait] impl MerchantConnectorAccountInterface for MockDb { + async fn update_multiple_merchant_connector_accounts( + &self, + _merchant_connector_accounts: Vec<( + domain::MerchantConnectorAccount, + storage::MerchantConnectorAccountUpdateInternal, + )>, + ) -> CustomResult<(), errors::StorageError> { + // No need to implement this function for `MockDb` as this function will be removed after the + // apple pay certificate migration + Err(errors::StorageError::MockDbError)? + } async fn find_merchant_connector_account_by_merchant_id_connector_label( &self, merchant_id: &str, @@ -664,6 +783,7 @@ impl MerchantConnectorAccountInterface for MockDb { applepay_verified_domains: t.applepay_verified_domains, pm_auth_config: t.pm_auth_config, status: t.status, + connector_wallets_details: t.connector_wallets_details.map(Encryption::from), }; accounts.push(account.clone()); account @@ -863,6 +983,14 @@ mod merchant_connector_account_cache_tests { applepay_verified_domains: None, pm_auth_config: None, status: common_enums::ConnectorStatus::Inactive, + connector_wallets_details: Some( + domain::types::encrypt( + serde_json::Value::default().into(), + merchant_key.key.get_inner().peek(), + ) + .await + .unwrap(), + ), }; db.insert_merchant_connector_account(mca.clone(), &merchant_key) diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index 7344b85d150d..73ec0f1635d5 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -144,6 +144,7 @@ pub fn mk_app( .service(routes::Routing::server(state.clone())) .service(routes::Blocklist::server(state.clone())) .service(routes::Gsm::server(state.clone())) + .service(routes::ApplePayCertificatesMigration::server(state.clone())) .service(routes::PaymentLink::server(state.clone())) .service(routes::User::server(state.clone())) .service(routes::ConnectorOnboarding::server(state.clone())) diff --git a/crates/router/src/routes.rs b/crates/router/src/routes.rs index 9f13635ca901..cb229b5c802a 100644 --- a/crates/router/src/routes.rs +++ b/crates/router/src/routes.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod api_keys; pub mod app; +pub mod apple_pay_certificates_migration; #[cfg(feature = "olap")] pub mod blocklist; pub mod cache; @@ -58,10 +59,10 @@ pub use self::app::Payouts; #[cfg(all(feature = "olap", feature = "recon"))] pub use self::app::Recon; pub use self::app::{ - ApiKeys, AppState, BusinessProfile, Cache, Cards, Configs, ConnectorOnboarding, Customers, - Disputes, EphemeralKey, Files, Gsm, Health, Mandates, MerchantAccount, - MerchantConnectorAccount, PaymentLink, PaymentMethods, Payments, Poll, Refunds, SessionState, - User, Webhooks, + ApiKeys, AppState, ApplePayCertificatesMigration, BusinessProfile, Cache, Cards, Configs, + ConnectorOnboarding, Customers, Disputes, EphemeralKey, Files, Gsm, Health, Mandates, + MerchantAccount, MerchantConnectorAccount, PaymentLink, PaymentMethods, Payments, Poll, + Refunds, SessionState, User, Webhooks, }; #[cfg(feature = "olap")] pub use self::app::{Blocklist, Routing, Verify, WebhookEvents}; diff --git a/crates/router/src/routes/admin.rs b/crates/router/src/routes/admin.rs index 2d1b77460a5f..cd3927565051 100644 --- a/crates/router/src/routes/admin.rs +++ b/crates/router/src/routes/admin.rs @@ -413,7 +413,7 @@ pub async fn payment_connector_delete( merchant_connector_id, }) .into_inner(); - api::server_wrap( + Box::pin(api::server_wrap( flow, state, &req, @@ -430,7 +430,7 @@ pub async fn payment_connector_delete( req.headers(), ), api_locking::LockAction::NotApplicable, - ) + )) .await } /// Merchant Account - Toggle KV diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 587ecd9e63d9..83d8c714e923 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -30,8 +30,8 @@ use super::routing as cloud_routing; use super::verification::{apple_pay_merchant_registration, retrieve_apple_pay_verified_domains}; #[cfg(feature = "olap")] use super::{ - admin::*, api_keys::*, connector_onboarding::*, disputes::*, files::*, gsm::*, payment_link::*, - user::*, user_role::*, webhook_events::*, + admin::*, api_keys::*, apple_pay_certificates_migration, connector_onboarding::*, disputes::*, + files::*, gsm::*, payment_link::*, user::*, user_role::*, webhook_events::*, }; use super::{cache::*, health::*}; #[cfg(any(feature = "olap", feature = "oltp"))] @@ -1142,6 +1142,19 @@ impl Configs { } } +pub struct ApplePayCertificatesMigration; + +#[cfg(feature = "olap")] +impl ApplePayCertificatesMigration { + pub fn server(state: AppState) -> Scope { + web::scope("/apple_pay_certificates_migration") + .app_data(web::Data::new(state)) + .service(web::resource("").route( + web::post().to(apple_pay_certificates_migration::apple_pay_certificates_migration), + )) + } +} + pub struct Poll; #[cfg(feature = "oltp")] diff --git a/crates/router/src/routes/apple_pay_certificates_migration.rs b/crates/router/src/routes/apple_pay_certificates_migration.rs new file mode 100644 index 000000000000..8c9d507ba419 --- /dev/null +++ b/crates/router/src/routes/apple_pay_certificates_migration.rs @@ -0,0 +1,30 @@ +use actix_web::{web, HttpRequest, HttpResponse}; +use router_env::Flow; + +use super::AppState; +use crate::{ + core::{api_locking, apple_pay_certificates_migration}, + services::{api, authentication as auth}, +}; + +pub async fn apple_pay_certificates_migration( + state: web::Data, + req: HttpRequest, + json_payload: web::Json< + api_models::apple_pay_certificates_migration::ApplePayCertificatesMigrationRequest, + >, +) -> HttpResponse { + let flow = Flow::ApplePayCertificatesMigration; + Box::pin(api::server_wrap( + flow, + state, + &req, + &json_payload.into_inner(), + |state, _, req, _| { + apple_pay_certificates_migration::apple_pay_certificates_migration(state, req) + }, + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index b4b9658a7e02..a64343757b5e 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -35,6 +35,7 @@ pub enum ApiIdentifier { ConnectorOnboarding, Recon, Poll, + ApplePayCertificatesMigration, } impl From for ApiIdentifier { @@ -186,6 +187,8 @@ impl From for ApiIdentifier { | Flow::GsmRuleUpdate | Flow::GsmRuleDelete => Self::Gsm, + Flow::ApplePayCertificatesMigration => Self::ApplePayCertificatesMigration, + Flow::UserConnectAccount | Flow::UserSignUp | Flow::UserSignIn diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index 35d1f989d73b..ff2df97f28d3 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -805,6 +805,7 @@ impl ForeignFrom<(&RouterData, T2) address: data.address.clone(), auth_type: data.auth_type, connector_meta_data: data.connector_meta_data.clone(), + connector_wallets_details: data.connector_wallets_details.clone(), amount_captured: data.amount_captured, access_token: data.access_token.clone(), response: data.response.clone(), @@ -865,6 +866,7 @@ impl address: data.address.clone(), auth_type: data.auth_type, connector_meta_data: data.connector_meta_data.clone(), + connector_wallets_details: data.connector_wallets_details.clone(), amount_captured: data.amount_captured, access_token: data.access_token.clone(), response: data.response.clone(), diff --git a/crates/router/src/types/api/verify_connector.rs b/crates/router/src/types/api/verify_connector.rs index 0b932f9aeb1e..7e296d0b4a87 100644 --- a/crates/router/src/types/api/verify_connector.rs +++ b/crates/router/src/types/api/verify_connector.rs @@ -85,6 +85,7 @@ impl VerifyConnectorData { connector_customer: None, connector_auth_type: self.connector_auth.clone(), connector_meta_data: None, + connector_wallets_details: None, payment_method_token: None, connector_api_version: None, recurring_mandate_payment_data: None, diff --git a/crates/router/src/types/domain/merchant_connector_account.rs b/crates/router/src/types/domain/merchant_connector_account.rs index 0e7f5b081c3b..6c2f6a06e1ed 100644 --- a/crates/router/src/types/domain/merchant_connector_account.rs +++ b/crates/router/src/types/domain/merchant_connector_account.rs @@ -11,7 +11,10 @@ use diesel_models::{ use error_stack::ResultExt; use masking::{PeekInterface, Secret}; -use super::{behaviour, types::TypeEncryption}; +use super::{ + behaviour, + types::{self, AsyncLift, TypeEncryption}, +}; #[derive(Clone, Debug)] pub struct MerchantConnectorAccount { pub id: Option, @@ -36,6 +39,7 @@ pub struct MerchantConnectorAccount { pub applepay_verified_domains: Option>, pub pm_auth_config: Option, pub status: enums::ConnectorStatus, + pub connector_wallets_details: Option>>, } #[derive(Debug)] @@ -56,6 +60,10 @@ pub enum MerchantConnectorAccountUpdate { pm_auth_config: Option, connector_label: Option, status: Option, + connector_wallets_details: Option>>, + }, + ConnectorWalletDetailsUpdate { + connector_wallets_details: Encryptable>, }, } @@ -92,6 +100,7 @@ impl behaviour::Conversion for MerchantConnectorAccount { applepay_verified_domains: self.applepay_verified_domains, pm_auth_config: self.pm_auth_config, status: self.status, + connector_wallets_details: self.connector_wallets_details.map(Encryption::from), }, ) } @@ -132,6 +141,13 @@ impl behaviour::Conversion for MerchantConnectorAccount { applepay_verified_domains: other.applepay_verified_domains, pm_auth_config: other.pm_auth_config, status: other.status, + connector_wallets_details: other + .connector_wallets_details + .async_lift(|inner| types::decrypt(inner, key.peek())) + .await + .change_context(ValidationError::InvalidValue { + message: "Failed while decrypting connector wallets details".to_string(), + })?, }) } @@ -160,6 +176,7 @@ impl behaviour::Conversion for MerchantConnectorAccount { applepay_verified_domains: self.applepay_verified_domains, pm_auth_config: self.pm_auth_config, status: self.status, + connector_wallets_details: self.connector_wallets_details.map(Encryption::from), }) } } @@ -183,6 +200,7 @@ impl From for MerchantConnectorAccountUpdateInte pm_auth_config, connector_label, status, + connector_wallets_details, } => Self { merchant_id, connector_type, @@ -201,6 +219,29 @@ impl From for MerchantConnectorAccountUpdateInte pm_auth_config, connector_label, status, + connector_wallets_details: connector_wallets_details.map(Encryption::from), + }, + MerchantConnectorAccountUpdate::ConnectorWalletDetailsUpdate { + connector_wallets_details, + } => Self { + connector_wallets_details: Some(Encryption::from(connector_wallets_details)), + merchant_id: None, + connector_type: None, + connector_name: None, + connector_account_details: None, + connector_label: None, + test_mode: None, + disabled: None, + merchant_connector_id: None, + payment_methods_enabled: None, + frm_configs: None, + metadata: None, + modified_at: None, + connector_webhook_details: None, + frm_config: None, + applepay_verified_domains: None, + pm_auth_config: None, + status: None, }, } } diff --git a/crates/router/src/types/domain/payments.rs b/crates/router/src/types/domain/payments.rs index 51d3210a70f5..021505c644f1 100644 --- a/crates/router/src/types/domain/payments.rs +++ b/crates/router/src/types/domain/payments.rs @@ -1,10 +1,10 @@ pub use hyperswitch_domain_models::payment_method_data::{ - AliPayQr, ApplePayThirdPartySdkData, ApplePayWalletData, ApplepayPaymentMethod, BankDebitData, - BankRedirectData, BankTransferData, BoletoVoucherData, Card, CardRedirectData, CardToken, - CashappQr, CryptoData, GcashRedirection, GiftCardData, GiftCardDetails, GoPayRedirection, - GooglePayPaymentMethodInfo, GooglePayRedirectData, GooglePayThirdPartySdkData, - GooglePayWalletData, GpayTokenizationData, IndomaretVoucherData, KakaoPayRedirection, - MbWayRedirection, PayLaterData, PaymentMethodData, SamsungPayWalletData, + AliPayQr, ApplePayFlow, ApplePayThirdPartySdkData, ApplePayWalletData, ApplepayPaymentMethod, + BankDebitData, BankRedirectData, BankTransferData, BoletoVoucherData, Card, CardRedirectData, + CardToken, CashappQr, CryptoData, GcashRedirection, GiftCardData, GiftCardDetails, + GoPayRedirection, GooglePayPaymentMethodInfo, GooglePayRedirectData, + GooglePayThirdPartySdkData, GooglePayWalletData, GpayTokenizationData, IndomaretVoucherData, + KakaoPayRedirection, MbWayRedirection, PayLaterData, PaymentMethodData, SamsungPayWalletData, SepaAndBacsBillingDetails, SwishQrData, TouchNGoRedirection, UpiCollectData, UpiData, UpiIntentData, VoucherData, WalletData, WeChatPayQr, }; diff --git a/crates/router/src/utils.rs b/crates/router/src/utils.rs index 328d4fbdc63c..701eda5d1c05 100644 --- a/crates/router/src/utils.rs +++ b/crates/router/src/utils.rs @@ -772,13 +772,13 @@ impl CustomerAddress for api_models::customers::CustomerRequest { } pub fn add_apple_pay_flow_metrics( - apple_pay_flow: &Option, + apple_pay_flow: &Option, connector: Option, merchant_id: String, ) { if let Some(flow) = apple_pay_flow { match flow { - enums::ApplePayFlow::Simplified => metrics::APPLE_PAY_SIMPLIFIED_FLOW.add( + domain::ApplePayFlow::Simplified(_) => metrics::APPLE_PAY_SIMPLIFIED_FLOW.add( &metrics::CONTEXT, 1, &[ @@ -789,7 +789,7 @@ pub fn add_apple_pay_flow_metrics( metrics::request::add_attributes("merchant_id", merchant_id.to_owned()), ], ), - enums::ApplePayFlow::Manual => metrics::APPLE_PAY_MANUAL_FLOW.add( + domain::ApplePayFlow::Manual => metrics::APPLE_PAY_MANUAL_FLOW.add( &metrics::CONTEXT, 1, &[ @@ -806,14 +806,14 @@ pub fn add_apple_pay_flow_metrics( pub fn add_apple_pay_payment_status_metrics( payment_attempt_status: enums::AttemptStatus, - apple_pay_flow: Option, + apple_pay_flow: Option, connector: Option, merchant_id: String, ) { if payment_attempt_status == enums::AttemptStatus::Charged { if let Some(flow) = apple_pay_flow { match flow { - enums::ApplePayFlow::Simplified => { + domain::ApplePayFlow::Simplified(_) => { metrics::APPLE_PAY_SIMPLIFIED_FLOW_SUCCESSFUL_PAYMENT.add( &metrics::CONTEXT, 1, @@ -826,7 +826,7 @@ pub fn add_apple_pay_payment_status_metrics( ], ) } - enums::ApplePayFlow::Manual => metrics::APPLE_PAY_MANUAL_FLOW_SUCCESSFUL_PAYMENT + domain::ApplePayFlow::Manual => metrics::APPLE_PAY_MANUAL_FLOW_SUCCESSFUL_PAYMENT .add( &metrics::CONTEXT, 1, @@ -843,7 +843,7 @@ pub fn add_apple_pay_payment_status_metrics( } else if payment_attempt_status == enums::AttemptStatus::Failure { if let Some(flow) = apple_pay_flow { match flow { - enums::ApplePayFlow::Simplified => { + domain::ApplePayFlow::Simplified(_) => { metrics::APPLE_PAY_SIMPLIFIED_FLOW_FAILED_PAYMENT.add( &metrics::CONTEXT, 1, @@ -856,7 +856,7 @@ pub fn add_apple_pay_payment_status_metrics( ], ) } - enums::ApplePayFlow::Manual => metrics::APPLE_PAY_MANUAL_FLOW_FAILED_PAYMENT.add( + domain::ApplePayFlow::Manual => metrics::APPLE_PAY_MANUAL_FLOW_FAILED_PAYMENT.add( &metrics::CONTEXT, 1, &[ diff --git a/crates/router/tests/connectors/aci.rs b/crates/router/tests/connectors/aci.rs index 10e8b3665303..b01c5abb968a 100644 --- a/crates/router/tests/connectors/aci.rs +++ b/crates/router/tests/connectors/aci.rs @@ -97,6 +97,7 @@ fn construct_payment_router_data() -> types::PaymentsAuthorizeRouterData { None, ), connector_meta_data: None, + connector_wallets_details: None, amount_captured: None, access_token: None, session_token: None, @@ -159,6 +160,7 @@ fn construct_refund_router_data() -> types::RefundsRouterData { response: Err(types::ErrorResponse::default()), address: PaymentAddress::default(), connector_meta_data: None, + connector_wallets_details: None, amount_captured: None, access_token: None, session_token: None, diff --git a/crates/router/tests/connectors/utils.rs b/crates/router/tests/connectors/utils.rs index a1f353f4bc14..75e30a72d9a2 100644 --- a/crates/router/tests/connectors/utils.rs +++ b/crates/router/tests/connectors/utils.rs @@ -541,6 +541,7 @@ pub trait ConnectorActions: Connector { connector_meta_data: info .clone() .and_then(|a| a.connector_meta_data.map(Secret::new)), + connector_wallets_details: None, amount_captured: None, access_token: info.clone().and_then(|a| a.access_token), session_token: None, diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 110532f524de..51f762e3713a 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -298,6 +298,8 @@ pub enum Flow { GsmRuleRetrieve, /// Gsm Rule Update flow GsmRuleUpdate, + /// Apple pay certificates migration + ApplePayCertificatesMigration, /// Gsm Rule Delete flow GsmRuleDelete, /// User Sign Up diff --git a/crates/storage_impl/src/errors.rs b/crates/storage_impl/src/errors.rs index 4356b6b79979..b153c47b8821 100644 --- a/crates/storage_impl/src/errors.rs +++ b/crates/storage_impl/src/errors.rs @@ -101,6 +101,12 @@ impl From> for StorageError { } } +impl From for StorageError { + fn from(err: diesel::result::Error) -> Self { + Self::from(error_stack::report!(DatabaseError::from(err))) + } +} + impl From> for StorageError { fn from(err: error_stack::Report) -> Self { Self::DatabaseError(err) diff --git a/migrations/2024-05-28-054439_connector_wallets_details/down.sql b/migrations/2024-05-28-054439_connector_wallets_details/down.sql new file mode 100644 index 000000000000..dea26bbd1eb4 --- /dev/null +++ b/migrations/2024-05-28-054439_connector_wallets_details/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE merchant_connector_account DROP COLUMN IF EXISTS connector_wallets_details; \ No newline at end of file diff --git a/migrations/2024-05-28-054439_connector_wallets_details/up.sql b/migrations/2024-05-28-054439_connector_wallets_details/up.sql new file mode 100644 index 000000000000..c75de9204df6 --- /dev/null +++ b/migrations/2024-05-28-054439_connector_wallets_details/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE merchant_connector_account ADD COLUMN IF NOT EXISTS connector_wallets_details BYTEA DEFAULT NULL; \ No newline at end of file diff --git a/openapi/openapi_spec.json b/openapi/openapi_spec.json index 434e6e267810..c9b0ed744743 100644 --- a/openapi/openapi_spec.json +++ b/openapi/openapi_spec.json @@ -13813,6 +13813,63 @@ }, "additionalProperties": false }, + "PaymentProcessingDetails": { + "type": "object", + "required": [ + "payment_processing_certificate", + "payment_processing_certificate_key" + ], + "properties": { + "payment_processing_certificate": { + "type": "string" + }, + "payment_processing_certificate_key": { + "type": "string" + } + } + }, + "PaymentProcessingDetailsAt": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/PaymentProcessingDetails" + }, + { + "type": "object", + "required": [ + "payment_processing_details_at" + ], + "properties": { + "payment_processing_details_at": { + "type": "string", + "enum": [ + "Hyperswitch" + ] + } + } + } + ] + }, + { + "type": "object", + "required": [ + "payment_processing_details_at" + ], + "properties": { + "payment_processing_details_at": { + "type": "string", + "enum": [ + "Connector" + ] + } + } + } + ], + "discriminator": { + "propertyName": "payment_processing_details_at" + } + }, "PaymentRetrieveBody": { "type": "object", "properties": { @@ -18544,43 +18601,55 @@ } }, "SessionTokenInfo": { - "type": "object", - "required": [ - "certificate", - "certificate_keys", - "merchant_identifier", - "display_name", - "initiative", - "initiative_context" - ], - "properties": { - "certificate": { - "type": "string" - }, - "certificate_keys": { - "type": "string" - }, - "merchant_identifier": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "initiative": { - "type": "string" - }, - "initiative_context": { - "type": "string" - }, - "merchant_business_country": { + "allOf": [ + { "allOf": [ { - "$ref": "#/components/schemas/CountryAlpha2" + "$ref": "#/components/schemas/PaymentProcessingDetailsAt" } ], "nullable": true + }, + { + "type": "object", + "required": [ + "certificate", + "certificate_keys", + "merchant_identifier", + "display_name", + "initiative", + "initiative_context" + ], + "properties": { + "certificate": { + "type": "string" + }, + "certificate_keys": { + "type": "string" + }, + "merchant_identifier": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "initiative": { + "type": "string" + }, + "initiative_context": { + "type": "string" + }, + "merchant_business_country": { + "allOf": [ + { + "$ref": "#/components/schemas/CountryAlpha2" + } + ], + "nullable": true + } + } } - } + ] }, "StraightThroughAlgorithm": { "oneOf": [ From b1cb053a55e9ce4d78f7770b53e39700311d9cd4 Mon Sep 17 00:00:00 2001 From: Sanchith Hegde <22217505+SanchithHegde@users.noreply.github.com> Date: Thu, 6 Jun 2024 14:00:09 +0530 Subject: [PATCH 7/9] refactor(webhooks): extract incoming and outgoing webhooks into separate modules (#4870) --- crates/router/src/core/webhooks.rs | 2385 +---------------- crates/router/src/core/webhooks/incoming.rs | 1628 +++++++++++ crates/router/src/core/webhooks/outgoing.rs | 775 ++++++ .../src/core/webhooks/webhook_events.rs | 2 +- crates/router/src/routes/webhooks.rs | 2 +- 5 files changed, 2412 insertions(+), 2380 deletions(-) create mode 100644 crates/router/src/core/webhooks/incoming.rs create mode 100644 crates/router/src/core/webhooks/outgoing.rs diff --git a/crates/router/src/core/webhooks.rs b/crates/router/src/core/webhooks.rs index 225607777055..a209d90f316f 100644 --- a/crates/router/src/core/webhooks.rs +++ b/crates/router/src/core/webhooks.rs @@ -1,2387 +1,16 @@ +mod incoming; +mod outgoing; pub mod types; pub mod utils; #[cfg(feature = "olap")] pub mod webhook_events; -use std::{str::FromStr, time::Instant}; - -use actix_web::FromRequest; -#[cfg(feature = "payouts")] -use api_models::payouts as payout_models; -use api_models::{ - payments::HeaderPayload, - webhook_events::{OutgoingWebhookRequestContent, OutgoingWebhookResponseContent}, - webhooks::{self, WebhookResponseTracker}, -}; -use common_utils::{ - errors::ReportSwitchExt, events::ApiEventsType, ext_traits::Encode, request::RequestContent, -}; -use error_stack::{report, ResultExt}; -use masking::{ExposeInterface, Mask, PeekInterface, Secret}; -use router_env::{ - instrument, - tracing::{self, Instrument}, - tracing_actix_web::RequestId, -}; - -#[cfg(feature = "payouts")] -use super::payouts; -use super::{errors::StorageErrorExt, metrics}; -#[cfg(feature = "stripe")] -use crate::compatibility::stripe::webhooks as stripe_webhooks; -#[cfg(not(feature = "payouts"))] -use crate::routes::SessionState; -use crate::{ - consts, - core::{ - api_locking, - errors::{self, ConnectorErrorExt, CustomResult, RouterResponse}, - payments, refunds, +pub(crate) use self::{ + incoming::incoming_webhooks_wrapper, + outgoing::{ + create_event_and_trigger_outgoing_webhook, get_outgoing_webhook_request, + trigger_webhook_and_raise_event, }, - db::StorageInterface, - events::{ - api_logs::ApiEvent, - outgoing_webhook_logs::{OutgoingWebhookEvent, OutgoingWebhookEventMetric}, - }, - logger, - routes::{ - app::{ReqState, SessionStateInfo}, - lock_utils, - metrics::request::add_attributes, - }, - services::{self, authentication as auth}, - types::{ - api::{self, mandates::MandateResponseExt}, - domain::{self, types as domain_types}, - storage::{self, enums}, - transformers::{ForeignFrom, ForeignInto, ForeignTryFrom}, - }, - utils::{self as helper_utils, generate_id, OptionExt, ValueExt}, - workflows::outgoing_webhook_retry, }; -#[cfg(feature = "payouts")] -use crate::{routes::SessionState, types::storage::PayoutAttemptUpdate}; -const OUTGOING_WEBHOOK_TIMEOUT_SECS: u64 = 5; const MERCHANT_ID: &str = "merchant_id"; - -pub async fn payments_incoming_webhook_flow( - state: SessionState, - req_state: ReqState, - merchant_account: domain::MerchantAccount, - business_profile: diesel_models::business_profile::BusinessProfile, - key_store: domain::MerchantKeyStore, - webhook_details: api::IncomingWebhookDetails, - source_verified: bool, -) -> CustomResult { - let consume_or_trigger_flow = if source_verified { - payments::CallConnectorAction::HandleResponse(webhook_details.resource_object) - } else { - payments::CallConnectorAction::Trigger - }; - let payments_response = match webhook_details.object_reference_id { - webhooks::ObjectReferenceId::PaymentId(id) => { - let payment_id = get_payment_id( - state.store.as_ref(), - &id, - merchant_account.merchant_id.as_str(), - merchant_account.storage_scheme, - ) - .await?; - - let lock_action = api_locking::LockAction::Hold { - input: api_locking::LockingInput { - unique_locking_key: payment_id, - api_identifier: lock_utils::ApiIdentifier::Payments, - override_lock_retries: None, - }, - }; - - lock_action - .clone() - .perform_locking_action(&state, merchant_account.merchant_id.to_string()) - .await?; - - let response = Box::pin(payments::payments_core::< - api::PSync, - api::PaymentsResponse, - _, - _, - _, - >( - state.clone(), - req_state, - merchant_account.clone(), - key_store.clone(), - payments::operations::PaymentStatus, - api::PaymentsRetrieveRequest { - resource_id: id, - merchant_id: Some(merchant_account.merchant_id.clone()), - force_sync: true, - connector: None, - param: None, - merchant_connector_details: None, - client_secret: None, - expand_attempts: None, - expand_captures: None, - }, - services::AuthFlow::Merchant, - consume_or_trigger_flow, - None, - HeaderPayload::default(), - )) - .await; - - lock_action - .free_lock_action(&state, merchant_account.merchant_id.to_owned()) - .await?; - - match response { - Ok(value) => value, - Err(err) - if matches!( - err.current_context(), - &errors::ApiErrorResponse::PaymentNotFound - ) && state - .conf - .webhooks - .ignore_error - .payment_not_found - .unwrap_or(true) => - { - metrics::WEBHOOK_PAYMENT_NOT_FOUND.add( - &metrics::CONTEXT, - 1, - &[add_attributes( - "merchant_id", - merchant_account.merchant_id.clone(), - )], - ); - return Ok(WebhookResponseTracker::NoEffect); - } - error @ Err(_) => error?, - } - } - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( - "Did not get payment id as object reference id in webhook payments flow", - )?, - }; - - match payments_response { - services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { - let payment_id = payments_response - .payment_id - .clone() - .get_required_value("payment_id") - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("payment id not received from payments core")?; - - let status = payments_response.status; - - let event_type: Option = payments_response.status.foreign_into(); - - // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook - if let Some(outgoing_event_type) = event_type { - let primary_object_created_at = payments_response.created; - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - outgoing_event_type, - enums::EventClass::Payments, - payment_id.clone(), - enums::EventObjectType::PaymentDetails, - api::OutgoingWebhookContent::PaymentDetails(payments_response), - primary_object_created_at, - ) - .await?; - }; - - let response = WebhookResponseTracker::Payment { payment_id, status }; - - Ok(response) - } - - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("received non-json response from payments core")?, - } -} - -#[cfg(feature = "payouts")] -pub async fn payouts_incoming_webhook_flow( - state: SessionState, - merchant_account: domain::MerchantAccount, - business_profile: diesel_models::business_profile::BusinessProfile, - key_store: domain::MerchantKeyStore, - webhook_details: api::IncomingWebhookDetails, - event_type: webhooks::IncomingWebhookEvent, - source_verified: bool, -) -> CustomResult { - metrics::INCOMING_PAYOUT_WEBHOOK_METRIC.add(&metrics::CONTEXT, 1, &[]); - if source_verified { - let db = &*state.store; - //find payout_attempt by object_reference_id - let payout_attempt = match webhook_details.object_reference_id { - webhooks::ObjectReferenceId::PayoutId(payout_id_type) => match payout_id_type { - webhooks::PayoutIdType::PayoutAttemptId(id) => db - .find_payout_attempt_by_merchant_id_payout_attempt_id( - &merchant_account.merchant_id, - &id, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the payout attempt")?, - webhooks::PayoutIdType::ConnectorPayoutId(id) => db - .find_payout_attempt_by_merchant_id_connector_payout_id( - &merchant_account.merchant_id, - &id, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the payout attempt")?, - }, - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("received a non-payout id when processing payout webhooks")?, - }; - - let payouts = db - .find_payout_by_merchant_id_payout_id( - &merchant_account.merchant_id, - &payout_attempt.payout_id, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the payout")?; - - let payout_attempt_update = PayoutAttemptUpdate::StatusUpdate { - connector_payout_id: payout_attempt.connector_payout_id.clone(), - status: common_enums::PayoutStatus::foreign_try_from(event_type) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("failed payout status mapping from event type")?, - error_message: None, - error_code: None, - is_eligible: payout_attempt.is_eligible, - }; - - let action_req = - payout_models::PayoutRequest::PayoutActionRequest(payout_models::PayoutActionRequest { - payout_id: payouts.payout_id.clone(), - }); - - let payout_data = - payouts::make_payout_data(&state, &merchant_account, &key_store, &action_req).await?; - - let updated_payout_attempt = db - .update_payout_attempt( - &payout_attempt, - payout_attempt_update, - &payout_data.payouts, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable_lazy(|| { - format!( - "Failed while updating payout attempt: payout_attempt_id: {}", - payout_attempt.payout_attempt_id - ) - })?; - - let event_type: Option = updated_payout_attempt.status.foreign_into(); - - // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook - if let Some(outgoing_event_type) = event_type { - let router_response = - payouts::response_handler(&merchant_account, &payout_data).await?; - - let payout_create_response: payout_models::PayoutCreateResponse = match router_response - { - services::ApplicationResponse::Json(response) => response, - _ => Err(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the payout create response")?, - }; - - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - outgoing_event_type, - enums::EventClass::Payouts, - updated_payout_attempt.payout_id.clone(), - enums::EventObjectType::PayoutDetails, - api::OutgoingWebhookContent::PayoutDetails(payout_create_response), - Some(updated_payout_attempt.created_at), - ) - .await?; - } - - Ok(WebhookResponseTracker::Payout { - payout_id: updated_payout_attempt.payout_id, - status: updated_payout_attempt.status, - }) - } else { - metrics::INCOMING_PAYOUT_WEBHOOK_SIGNATURE_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]); - Err(report!( - errors::ApiErrorResponse::WebhookAuthenticationFailed - )) - } -} - -#[instrument(skip_all)] -#[allow(clippy::too_many_arguments)] -pub async fn refunds_incoming_webhook_flow( - state: SessionState, - merchant_account: domain::MerchantAccount, - business_profile: diesel_models::business_profile::BusinessProfile, - key_store: domain::MerchantKeyStore, - webhook_details: api::IncomingWebhookDetails, - connector_name: &str, - source_verified: bool, - event_type: webhooks::IncomingWebhookEvent, -) -> CustomResult { - let db = &*state.store; - //find refund by connector refund id - let refund = match webhook_details.object_reference_id { - webhooks::ObjectReferenceId::RefundId(refund_id_type) => match refund_id_type { - webhooks::RefundIdType::RefundId(id) => db - .find_refund_by_merchant_id_refund_id( - &merchant_account.merchant_id, - &id, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the refund")?, - webhooks::RefundIdType::ConnectorRefundId(id) => db - .find_refund_by_merchant_id_connector_refund_id_connector( - &merchant_account.merchant_id, - &id, - connector_name, - merchant_account.storage_scheme, - ) - .await - .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the refund")?, - }, - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("received a non-refund id when processing refund webhooks")?, - }; - let refund_id = refund.refund_id.to_owned(); - //if source verified then update refund status else trigger refund sync - let updated_refund = if source_verified { - let refund_update = storage::RefundUpdate::StatusUpdate { - connector_refund_id: None, - sent_to_gateway: true, - refund_status: common_enums::RefundStatus::foreign_try_from(event_type) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("failed refund status mapping from event type")?, - updated_by: merchant_account.storage_scheme.to_string(), - }; - db.update_refund( - refund.to_owned(), - refund_update, - merchant_account.storage_scheme, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable_lazy(|| format!("Failed while updating refund: refund_id: {refund_id}"))? - } else { - Box::pin(refunds::refund_retrieve_core( - state.clone(), - merchant_account.clone(), - key_store.clone(), - api_models::refunds::RefundsRetrieveRequest { - refund_id: refund_id.to_owned(), - force_sync: Some(true), - merchant_connector_details: None, - }, - )) - .await - .attach_printable_lazy(|| format!("Failed while updating refund: refund_id: {refund_id}"))? - }; - let event_type: Option = updated_refund.refund_status.foreign_into(); - - // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook - if let Some(outgoing_event_type) = event_type { - let refund_response: api_models::refunds::RefundResponse = - updated_refund.clone().foreign_into(); - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - outgoing_event_type, - enums::EventClass::Refunds, - refund_id, - enums::EventObjectType::RefundDetails, - api::OutgoingWebhookContent::RefundDetails(refund_response), - Some(updated_refund.created_at), - ) - .await?; - } - - Ok(WebhookResponseTracker::Refund { - payment_id: updated_refund.payment_id, - refund_id: updated_refund.refund_id, - status: updated_refund.refund_status, - }) -} - -pub async fn get_payment_attempt_from_object_reference_id( - state: &SessionState, - object_reference_id: webhooks::ObjectReferenceId, - merchant_account: &domain::MerchantAccount, -) -> CustomResult< - hyperswitch_domain_models::payments::payment_attempt::PaymentAttempt, - errors::ApiErrorResponse, -> { - let db = &*state.store; - match object_reference_id { - api::ObjectReferenceId::PaymentId(api::PaymentIdType::ConnectorTransactionId(ref id)) => db - .find_payment_attempt_by_merchant_id_connector_txn_id( - &merchant_account.merchant_id, - id, - merchant_account.storage_scheme, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), - api::ObjectReferenceId::PaymentId(api::PaymentIdType::PaymentAttemptId(ref id)) => db - .find_payment_attempt_by_attempt_id_merchant_id( - id, - &merchant_account.merchant_id, - merchant_account.storage_scheme, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), - api::ObjectReferenceId::PaymentId(api::PaymentIdType::PreprocessingId(ref id)) => db - .find_payment_attempt_by_preprocessing_id_merchant_id( - id, - &merchant_account.merchant_id, - merchant_account.storage_scheme, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("received a non-payment id for retrieving payment")?, - } -} - -#[allow(clippy::too_many_arguments)] -pub async fn get_or_update_dispute_object( - state: SessionState, - option_dispute: Option, - dispute_details: api::disputes::DisputePayload, - merchant_id: &str, - payment_attempt: &hyperswitch_domain_models::payments::payment_attempt::PaymentAttempt, - event_type: webhooks::IncomingWebhookEvent, - business_profile: &diesel_models::business_profile::BusinessProfile, - connector_name: &str, -) -> CustomResult { - let db = &*state.store; - match option_dispute { - None => { - metrics::INCOMING_DISPUTE_WEBHOOK_NEW_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]); - let dispute_id = generate_id(consts::ID_LENGTH, "dp"); - let new_dispute = diesel_models::dispute::DisputeNew { - dispute_id, - amount: dispute_details.amount.clone(), - currency: dispute_details.currency, - dispute_stage: dispute_details.dispute_stage, - dispute_status: common_enums::DisputeStatus::foreign_try_from(event_type) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("event type to dispute status mapping failed")?, - payment_id: payment_attempt.payment_id.to_owned(), - connector: connector_name.to_owned(), - attempt_id: payment_attempt.attempt_id.to_owned(), - merchant_id: merchant_id.to_owned(), - connector_status: dispute_details.connector_status, - connector_dispute_id: dispute_details.connector_dispute_id, - connector_reason: dispute_details.connector_reason, - connector_reason_code: dispute_details.connector_reason_code, - challenge_required_by: dispute_details.challenge_required_by, - connector_created_at: dispute_details.created_at, - connector_updated_at: dispute_details.updated_at, - profile_id: Some(business_profile.profile_id.clone()), - evidence: None, - merchant_connector_id: payment_attempt.merchant_connector_id.clone(), - dispute_amount: dispute_details.amount.parse::().unwrap_or(0), - }; - state - .store - .insert_dispute(new_dispute.clone()) - .await - .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) - } - Some(dispute) => { - logger::info!("Dispute Already exists, Updating the dispute details"); - metrics::INCOMING_DISPUTE_WEBHOOK_UPDATE_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]); - let dispute_status = diesel_models::enums::DisputeStatus::foreign_try_from(event_type) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("event type to dispute state conversion failure")?; - crate::core::utils::validate_dispute_stage_and_dispute_status( - dispute.dispute_stage, - dispute.dispute_status, - dispute_details.dispute_stage, - dispute_status, - ) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("dispute stage and status validation failed")?; - let update_dispute = diesel_models::dispute::DisputeUpdate::Update { - dispute_stage: dispute_details.dispute_stage, - dispute_status, - connector_status: dispute_details.connector_status, - connector_reason: dispute_details.connector_reason, - connector_reason_code: dispute_details.connector_reason_code, - challenge_required_by: dispute_details.challenge_required_by, - connector_updated_at: dispute_details.updated_at, - }; - db.update_dispute(dispute, update_dispute) - .await - .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) - } - } -} - -#[allow(clippy::too_many_arguments)] -pub async fn external_authentication_incoming_webhook_flow( - state: SessionState, - req_state: ReqState, - merchant_account: domain::MerchantAccount, - key_store: domain::MerchantKeyStore, - source_verified: bool, - event_type: webhooks::IncomingWebhookEvent, - request_details: &api::IncomingWebhookRequestDetails<'_>, - connector: &(dyn api::Connector + Sync), - object_ref_id: api::ObjectReferenceId, - business_profile: diesel_models::business_profile::BusinessProfile, - merchant_connector_account: domain::MerchantConnectorAccount, -) -> CustomResult { - if source_verified { - let authentication_details = connector - .get_external_authentication_details(request_details) - .switch()?; - let trans_status = authentication_details.trans_status; - let authentication_update = storage::AuthenticationUpdate::PostAuthenticationUpdate { - authentication_status: common_enums::AuthenticationStatus::foreign_from( - trans_status.clone(), - ), - trans_status, - authentication_value: authentication_details.authentication_value, - eci: authentication_details.eci, - }; - let authentication = - if let webhooks::ObjectReferenceId::ExternalAuthenticationID(authentication_id_type) = - object_ref_id - { - match authentication_id_type { - webhooks::AuthenticationIdType::AuthenticationId(authentication_id) => state - .store - .find_authentication_by_merchant_id_authentication_id( - merchant_account.merchant_id.clone(), - authentication_id.clone(), - ) - .await - .to_not_found_response(errors::ApiErrorResponse::AuthenticationNotFound { - id: authentication_id, - }) - .attach_printable("Error while fetching authentication record"), - webhooks::AuthenticationIdType::ConnectorAuthenticationId( - connector_authentication_id, - ) => state - .store - .find_authentication_by_merchant_id_connector_authentication_id( - merchant_account.merchant_id.clone(), - connector_authentication_id.clone(), - ) - .await - .to_not_found_response(errors::ApiErrorResponse::AuthenticationNotFound { - id: connector_authentication_id, - }) - .attach_printable("Error while fetching authentication record"), - } - } else { - Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( - "received a non-external-authentication id for retrieving authentication", - ) - }?; - let updated_authentication = state - .store - .update_authentication_by_merchant_id_authentication_id( - authentication, - authentication_update, - ) - .await - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Error while updating authentication")?; - // Check if it's a payment authentication flow, payment_id would be there only for payment authentication flows - if let Some(payment_id) = updated_authentication.payment_id { - let is_pull_mechanism_enabled = helper_utils::check_if_pull_mechanism_for_external_3ds_enabled_from_connector_metadata(merchant_connector_account.metadata.map(|metadata| metadata.expose())); - // Merchant doesn't have pull mechanism enabled and if it's challenge flow, we have to authorize whenever we receive a ARes webhook - if !is_pull_mechanism_enabled - && updated_authentication.authentication_type - == Some(common_enums::DecoupledAuthenticationType::Challenge) - && event_type == webhooks::IncomingWebhookEvent::ExternalAuthenticationARes - { - let payment_confirm_req = api::PaymentsRequest { - payment_id: Some(api_models::payments::PaymentIdType::PaymentIntentId( - payment_id, - )), - merchant_id: Some(merchant_account.merchant_id.clone()), - ..Default::default() - }; - let payments_response = Box::pin(payments::payments_core::< - api::Authorize, - api::PaymentsResponse, - _, - _, - _, - >( - state.clone(), - req_state, - merchant_account.clone(), - key_store.clone(), - payments::PaymentConfirm, - payment_confirm_req, - services::api::AuthFlow::Merchant, - payments::CallConnectorAction::Trigger, - None, - HeaderPayload::with_source(enums::PaymentSource::ExternalAuthenticator), - )) - .await?; - match payments_response { - services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { - let payment_id = payments_response - .payment_id - .clone() - .get_required_value("payment_id") - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("payment id not received from payments core")?; - let status = payments_response.status; - let event_type: Option = - payments_response.status.foreign_into(); - // Set poll_id as completed in redis to allow the fetch status of poll through retrieve_poll_status api from client - let poll_id = super::utils::get_poll_id( - merchant_account.merchant_id.clone(), - super::utils::get_external_authentication_request_poll_id(&payment_id), - ); - let redis_conn = state - .store - .get_redis_conn() - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Failed to get redis connection")?; - redis_conn - .set_key_without_modifying_ttl( - &poll_id, - api_models::poll::PollStatus::Completed.to_string(), - ) - .await - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Failed to add poll_id in redis")?; - // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook - if let Some(outgoing_event_type) = event_type { - let primary_object_created_at = payments_response.created; - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - outgoing_event_type, - enums::EventClass::Payments, - payment_id.clone(), - enums::EventObjectType::PaymentDetails, - api::OutgoingWebhookContent::PaymentDetails(payments_response), - primary_object_created_at, - ) - .await?; - }; - let response = WebhookResponseTracker::Payment { payment_id, status }; - Ok(response) - } - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( - "Did not get payment id as object reference id in webhook payments flow", - )?, - } - } else { - Ok(WebhookResponseTracker::NoEffect) - } - } else { - Ok(WebhookResponseTracker::NoEffect) - } - } else { - logger::error!( - "Webhook source verification failed for external authentication webhook flow" - ); - Err(report!( - errors::ApiErrorResponse::WebhookAuthenticationFailed - )) - } -} - -pub async fn mandates_incoming_webhook_flow( - state: SessionState, - merchant_account: domain::MerchantAccount, - business_profile: diesel_models::business_profile::BusinessProfile, - key_store: domain::MerchantKeyStore, - webhook_details: api::IncomingWebhookDetails, - source_verified: bool, - event_type: webhooks::IncomingWebhookEvent, -) -> CustomResult { - if source_verified { - let db = &*state.store; - let mandate = match webhook_details.object_reference_id { - webhooks::ObjectReferenceId::MandateId(webhooks::MandateIdType::MandateId( - mandate_id, - )) => db - .find_mandate_by_merchant_id_mandate_id( - &merchant_account.merchant_id, - mandate_id.as_str(), - merchant_account.storage_scheme, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?, - webhooks::ObjectReferenceId::MandateId( - webhooks::MandateIdType::ConnectorMandateId(connector_mandate_id), - ) => db - .find_mandate_by_merchant_id_connector_mandate_id( - &merchant_account.merchant_id, - connector_mandate_id.as_str(), - merchant_account.storage_scheme, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?, - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("received a non-mandate id for retrieving mandate")?, - }; - let mandate_status = common_enums::MandateStatus::foreign_try_from(event_type) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("event type to mandate status mapping failed")?; - let mandate_id = mandate.mandate_id.clone(); - let updated_mandate = db - .update_mandate_by_merchant_id_mandate_id( - &merchant_account.merchant_id, - &mandate_id, - storage::MandateUpdate::StatusUpdate { mandate_status }, - mandate, - merchant_account.storage_scheme, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?; - let mandates_response = Box::new( - api::mandates::MandateResponse::from_db_mandate( - &state, - key_store.clone(), - updated_mandate.clone(), - merchant_account.storage_scheme, - ) - .await?, - ); - let event_type: Option = updated_mandate.mandate_status.foreign_into(); - if let Some(outgoing_event_type) = event_type { - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - outgoing_event_type, - enums::EventClass::Mandates, - updated_mandate.mandate_id.clone(), - enums::EventObjectType::MandateDetails, - api::OutgoingWebhookContent::MandateDetails(mandates_response), - Some(updated_mandate.created_at), - ) - .await?; - } - Ok(WebhookResponseTracker::Mandate { - mandate_id: updated_mandate.mandate_id, - status: updated_mandate.mandate_status, - }) - } else { - logger::error!("Webhook source verification failed for mandates webhook flow"); - Err(report!( - errors::ApiErrorResponse::WebhookAuthenticationFailed - )) - } -} - -#[allow(clippy::too_many_arguments)] -#[instrument(skip_all)] -pub(crate) async fn frm_incoming_webhook_flow( - state: SessionState, - req_state: ReqState, - merchant_account: domain::MerchantAccount, - key_store: domain::MerchantKeyStore, - source_verified: bool, - event_type: webhooks::IncomingWebhookEvent, - object_ref_id: api::ObjectReferenceId, - business_profile: diesel_models::business_profile::BusinessProfile, -) -> CustomResult { - if source_verified { - let payment_attempt = - get_payment_attempt_from_object_reference_id(&state, object_ref_id, &merchant_account) - .await?; - let payment_response = match event_type { - webhooks::IncomingWebhookEvent::FrmApproved => { - Box::pin(payments::payments_core::< - api::Capture, - api::PaymentsResponse, - _, - _, - _, - >( - state.clone(), - req_state, - merchant_account.clone(), - key_store.clone(), - payments::PaymentApprove, - api::PaymentsCaptureRequest { - payment_id: payment_attempt.payment_id, - amount_to_capture: payment_attempt.amount_to_capture, - ..Default::default() - }, - services::api::AuthFlow::Merchant, - payments::CallConnectorAction::Trigger, - None, - HeaderPayload::default(), - )) - .await? - } - webhooks::IncomingWebhookEvent::FrmRejected => { - Box::pin(payments::payments_core::< - api::Void, - api::PaymentsResponse, - _, - _, - _, - >( - state.clone(), - req_state, - merchant_account.clone(), - key_store.clone(), - payments::PaymentReject, - api::PaymentsCancelRequest { - payment_id: payment_attempt.payment_id.clone(), - cancellation_reason: Some( - "Rejected by merchant based on FRM decision".to_string(), - ), - ..Default::default() - }, - services::api::AuthFlow::Merchant, - payments::CallConnectorAction::Trigger, - None, - HeaderPayload::default(), - )) - .await? - } - _ => Err(errors::ApiErrorResponse::EventNotFound)?, - }; - match payment_response { - services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { - let payment_id = payments_response - .payment_id - .clone() - .get_required_value("payment_id") - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("payment id not received from payments core")?; - let status = payments_response.status; - let event_type: Option = payments_response.status.foreign_into(); - if let Some(outgoing_event_type) = event_type { - let primary_object_created_at = payments_response.created; - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - outgoing_event_type, - enums::EventClass::Payments, - payment_id.clone(), - enums::EventObjectType::PaymentDetails, - api::OutgoingWebhookContent::PaymentDetails(payments_response), - primary_object_created_at, - ) - .await?; - }; - let response = WebhookResponseTracker::Payment { payment_id, status }; - Ok(response) - } - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( - "Did not get payment id as object reference id in webhook payments flow", - )?, - } - } else { - logger::error!("Webhook source verification failed for frm webhooks flow"); - Err(report!( - errors::ApiErrorResponse::WebhookAuthenticationFailed - )) - } -} - -#[allow(clippy::too_many_arguments)] -#[instrument(skip_all)] -pub async fn disputes_incoming_webhook_flow( - state: SessionState, - merchant_account: domain::MerchantAccount, - business_profile: diesel_models::business_profile::BusinessProfile, - key_store: domain::MerchantKeyStore, - webhook_details: api::IncomingWebhookDetails, - source_verified: bool, - connector: &(dyn api::Connector + Sync), - request_details: &api::IncomingWebhookRequestDetails<'_>, - event_type: webhooks::IncomingWebhookEvent, -) -> CustomResult { - metrics::INCOMING_DISPUTE_WEBHOOK_METRIC.add(&metrics::CONTEXT, 1, &[]); - if source_verified { - let db = &*state.store; - let dispute_details = connector.get_dispute_details(request_details).switch()?; - let payment_attempt = get_payment_attempt_from_object_reference_id( - &state, - webhook_details.object_reference_id, - &merchant_account, - ) - .await?; - let option_dispute = db - .find_by_merchant_id_payment_id_connector_dispute_id( - &merchant_account.merchant_id, - &payment_attempt.payment_id, - &dispute_details.connector_dispute_id, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound)?; - let dispute_object = get_or_update_dispute_object( - state.clone(), - option_dispute, - dispute_details, - &merchant_account.merchant_id, - &payment_attempt, - event_type, - &business_profile, - connector.id(), - ) - .await?; - let disputes_response = Box::new(dispute_object.clone().foreign_into()); - let event_type: enums::EventType = dispute_object.dispute_status.foreign_into(); - - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - event_type, - enums::EventClass::Disputes, - dispute_object.dispute_id.clone(), - enums::EventObjectType::DisputeDetails, - api::OutgoingWebhookContent::DisputeDetails(disputes_response), - Some(dispute_object.created_at), - ) - .await?; - metrics::INCOMING_DISPUTE_WEBHOOK_MERCHANT_NOTIFIED_METRIC.add(&metrics::CONTEXT, 1, &[]); - Ok(WebhookResponseTracker::Dispute { - dispute_id: dispute_object.dispute_id, - payment_id: dispute_object.payment_id, - status: dispute_object.dispute_status, - }) - } else { - metrics::INCOMING_DISPUTE_WEBHOOK_SIGNATURE_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]); - Err(report!( - errors::ApiErrorResponse::WebhookAuthenticationFailed - )) - } -} - -async fn bank_transfer_webhook_flow( - state: SessionState, - req_state: ReqState, - merchant_account: domain::MerchantAccount, - business_profile: diesel_models::business_profile::BusinessProfile, - key_store: domain::MerchantKeyStore, - webhook_details: api::IncomingWebhookDetails, - source_verified: bool, -) -> CustomResult { - let response = if source_verified { - let payment_attempt = get_payment_attempt_from_object_reference_id( - &state, - webhook_details.object_reference_id, - &merchant_account, - ) - .await?; - let payment_id = payment_attempt.payment_id; - let request = api::PaymentsRequest { - payment_id: Some(api_models::payments::PaymentIdType::PaymentIntentId( - payment_id, - )), - payment_token: payment_attempt.payment_token, - ..Default::default() - }; - Box::pin(payments::payments_core::< - api::Authorize, - api::PaymentsResponse, - _, - _, - _, - >( - state.clone(), - req_state, - merchant_account.to_owned(), - key_store.clone(), - payments::PaymentConfirm, - request, - services::api::AuthFlow::Merchant, - payments::CallConnectorAction::Trigger, - None, - HeaderPayload::with_source(common_enums::PaymentSource::Webhook), - )) - .await - } else { - Err(report!( - errors::ApiErrorResponse::WebhookAuthenticationFailed - )) - }; - - match response? { - services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { - let payment_id = payments_response - .payment_id - .clone() - .get_required_value("payment_id") - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("did not receive payment id from payments core response")?; - - let event_type: Option = payments_response.status.foreign_into(); - let status = payments_response.status; - - // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook - if let Some(outgoing_event_type) = event_type { - let primary_object_created_at = payments_response.created; - create_event_and_trigger_outgoing_webhook( - state, - merchant_account, - business_profile, - &key_store, - outgoing_event_type, - enums::EventClass::Payments, - payment_id.clone(), - enums::EventObjectType::PaymentDetails, - api::OutgoingWebhookContent::PaymentDetails(payments_response), - primary_object_created_at, - ) - .await?; - } - - Ok(WebhookResponseTracker::Payment { payment_id, status }) - } - - _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("received non-json response from payments core")?, - } -} - -#[allow(clippy::too_many_arguments)] -#[instrument(skip_all)] -pub(crate) async fn create_event_and_trigger_outgoing_webhook( - state: SessionState, - merchant_account: domain::MerchantAccount, - business_profile: diesel_models::business_profile::BusinessProfile, - merchant_key_store: &domain::MerchantKeyStore, - event_type: enums::EventType, - event_class: enums::EventClass, - primary_object_id: String, - primary_object_type: enums::EventObjectType, - content: api::OutgoingWebhookContent, - primary_object_created_at: Option, -) -> CustomResult<(), errors::ApiErrorResponse> { - let delivery_attempt = enums::WebhookDeliveryAttempt::InitialAttempt; - let idempotent_event_id = - utils::get_idempotent_event_id(&primary_object_id, event_type, delivery_attempt); - let webhook_url_result = get_webhook_url_from_business_profile(&business_profile); - - if !state.conf.webhooks.outgoing_enabled - || webhook_url_result.is_err() - || webhook_url_result.as_ref().is_ok_and(String::is_empty) - { - logger::debug!( - business_profile_id=%business_profile.profile_id, - %idempotent_event_id, - "Outgoing webhooks are disabled in application configuration, or merchant webhook URL \ - could not be obtained; skipping outgoing webhooks for event" - ); - return Ok(()); - } - - let event_id = utils::generate_event_id(); - let merchant_id = business_profile.merchant_id.clone(); - let now = common_utils::date_time::now(); - - let outgoing_webhook = api::OutgoingWebhook { - merchant_id: merchant_id.clone(), - event_id: event_id.clone(), - event_type, - content: content.clone(), - timestamp: now, - }; - - let request_content = get_outgoing_webhook_request( - &merchant_account, - outgoing_webhook, - business_profile.payment_response_hash_key.as_deref(), - ) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("Failed to construct outgoing webhook request content")?; - - let new_event = domain::Event { - event_id: event_id.clone(), - event_type, - event_class, - is_webhook_notified: false, - primary_object_id, - primary_object_type, - created_at: now, - merchant_id: Some(business_profile.merchant_id.clone()), - business_profile_id: Some(business_profile.profile_id.clone()), - primary_object_created_at, - idempotent_event_id: Some(idempotent_event_id.clone()), - initial_attempt_id: Some(event_id.clone()), - request: Some( - domain_types::encrypt( - request_content - .encode_to_string_of_json() - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("Failed to encode outgoing webhook request content") - .map(Secret::new)?, - merchant_key_store.key.get_inner().peek(), - ) - .await - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("Failed to encrypt outgoing webhook request content")?, - ), - response: None, - delivery_attempt: Some(delivery_attempt), - }; - - let event_insert_result = state - .store - .insert_event(new_event, merchant_key_store) - .await; - - let event = match event_insert_result { - Ok(event) => Ok(event), - Err(error) => { - if error.current_context().is_db_unique_violation() { - logger::debug!("Event with idempotent ID `{idempotent_event_id}` already exists in the database"); - return Ok(()); - } else { - logger::error!(event_insertion_failure=?error); - Err(error - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("Failed to insert event in events table")) - } - } - }?; - - let process_tracker = add_outgoing_webhook_retry_task_to_process_tracker( - &*state.store, - &business_profile, - &event, - ) - .await - .map_err(|error| { - logger::error!( - ?error, - "Failed to add outgoing webhook retry task to process tracker" - ); - error - }) - .ok(); - - let cloned_key_store = merchant_key_store.clone(); - // Using a tokio spawn here and not arbiter because not all caller of this function - // may have an actix arbiter - tokio::spawn( - async move { - Box::pin(trigger_webhook_and_raise_event( - state, - business_profile, - &cloned_key_store, - event, - request_content, - delivery_attempt, - Some(content), - process_tracker, - )) - .await; - } - .in_current_span(), - ); - - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -#[instrument(skip_all)] -pub(crate) async fn trigger_webhook_and_raise_event( - state: SessionState, - business_profile: diesel_models::business_profile::BusinessProfile, - merchant_key_store: &domain::MerchantKeyStore, - event: domain::Event, - request_content: OutgoingWebhookRequestContent, - delivery_attempt: enums::WebhookDeliveryAttempt, - content: Option, - process_tracker: Option, -) { - logger::debug!( - event_id=%event.event_id, - idempotent_event_id=?event.idempotent_event_id, - initial_attempt_id=?event.initial_attempt_id, - "Attempting to send webhook" - ); - - let merchant_id = business_profile.merchant_id.clone(); - let trigger_webhook_result = trigger_webhook_to_merchant( - state.clone(), - business_profile, - merchant_key_store, - event.clone(), - request_content, - delivery_attempt, - process_tracker, - ) - .await; - - raise_webhooks_analytics_event(state, trigger_webhook_result, content, merchant_id, event); -} - -async fn trigger_webhook_to_merchant( - state: SessionState, - business_profile: diesel_models::business_profile::BusinessProfile, - merchant_key_store: &domain::MerchantKeyStore, - event: domain::Event, - request_content: OutgoingWebhookRequestContent, - delivery_attempt: enums::WebhookDeliveryAttempt, - process_tracker: Option, -) -> CustomResult<(), errors::WebhooksFlowError> { - let webhook_url = match ( - get_webhook_url_from_business_profile(&business_profile), - process_tracker.clone(), - ) { - (Ok(webhook_url), _) => Ok(webhook_url), - (Err(error), Some(process_tracker)) => { - if !error - .current_context() - .is_webhook_delivery_retryable_error() - { - logger::debug!("Failed to obtain merchant webhook URL, aborting retries"); - state - .store - .as_scheduler() - .finish_process_with_business_status(process_tracker, "FAILURE".into()) - .await - .change_context( - errors::WebhooksFlowError::OutgoingWebhookProcessTrackerTaskUpdateFailed, - )?; - } - Err(error) - } - (Err(error), None) => Err(error), - }?; - - let event_id = event.event_id; - - let headers = request_content - .headers - .into_iter() - .map(|(name, value)| (name, value.into_masked())) - .collect(); - let request = services::RequestBuilder::new() - .method(services::Method::Post) - .url(&webhook_url) - .attach_default_headers() - .headers(headers) - .set_body(RequestContent::RawBytes( - request_content.body.expose().into_bytes(), - )) - .build(); - - let response = state - .api_client - .send_request(&state, request, Some(OUTGOING_WEBHOOK_TIMEOUT_SECS), false) - .await; - - metrics::WEBHOOK_OUTGOING_COUNT.add( - &metrics::CONTEXT, - 1, - &[metrics::KeyValue::new( - MERCHANT_ID, - business_profile.merchant_id.clone(), - )], - ); - logger::debug!(outgoing_webhook_response=?response); - - let update_event_if_client_error = - |state: SessionState, - merchant_key_store: domain::MerchantKeyStore, - merchant_id: String, - event_id: String, - error_message: String| async move { - let is_webhook_notified = false; - - let response_to_store = OutgoingWebhookResponseContent { - body: None, - headers: None, - status_code: None, - error_message: Some(error_message), - }; - - let event_update = domain::EventUpdate::UpdateResponse { - is_webhook_notified, - response: Some( - domain_types::encrypt( - response_to_store - .encode_to_string_of_json() - .change_context( - errors::WebhooksFlowError::OutgoingWebhookResponseEncodingFailed, - ) - .map(Secret::new)?, - merchant_key_store.key.get_inner().peek(), - ) - .await - .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) - .attach_printable("Failed to encrypt outgoing webhook response content")?, - ), - }; - - state - .store - .update_event_by_merchant_id_event_id( - &merchant_id, - &event_id, - event_update, - &merchant_key_store, - ) - .await - .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) - }; - - let api_client_error_handler = - |state: SessionState, - merchant_key_store: domain::MerchantKeyStore, - merchant_id: String, - event_id: String, - client_error: error_stack::Report, - delivery_attempt: enums::WebhookDeliveryAttempt| async move { - // Not including detailed error message in response information since it contains too - // much of diagnostic information to be exposed to the merchant. - update_event_if_client_error( - state, - merchant_key_store, - merchant_id, - event_id, - "Unable to send request to merchant server".to_string(), - ) - .await?; - - let error = - client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed); - logger::error!( - ?error, - ?delivery_attempt, - "An error occurred when sending webhook to merchant" - ); - - Ok::<_, error_stack::Report>(()) - }; - let update_event_in_storage = |state: SessionState, - merchant_key_store: domain::MerchantKeyStore, - merchant_id: String, - event_id: String, - response: reqwest::Response| async move { - let status_code = response.status(); - let is_webhook_notified = status_code.is_success(); - - let response_headers = response - .headers() - .iter() - .map(|(name, value)| { - ( - name.as_str().to_owned(), - value - .to_str() - .map(|s| Secret::from(String::from(s))) - .unwrap_or_else(|error| { - logger::warn!( - "Response header {} contains non-UTF-8 characters: {error:?}", - name.as_str() - ); - Secret::from(String::from("Non-UTF-8 header value")) - }), - ) - }) - .collect::>(); - let response_body = response - .text() - .await - .map(Secret::from) - .unwrap_or_else(|error| { - logger::warn!("Response contains non-UTF-8 characters: {error:?}"); - Secret::from(String::from("Non-UTF-8 response body")) - }); - let response_to_store = OutgoingWebhookResponseContent { - body: Some(response_body), - headers: Some(response_headers), - status_code: Some(status_code.as_u16()), - error_message: None, - }; - - let event_update = domain::EventUpdate::UpdateResponse { - is_webhook_notified, - response: Some( - domain_types::encrypt( - response_to_store - .encode_to_string_of_json() - .change_context( - errors::WebhooksFlowError::OutgoingWebhookResponseEncodingFailed, - ) - .map(Secret::new)?, - merchant_key_store.key.get_inner().peek(), - ) - .await - .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) - .attach_printable("Failed to encrypt outgoing webhook response content")?, - ), - }; - state - .store - .update_event_by_merchant_id_event_id( - &merchant_id, - &event_id, - event_update, - &merchant_key_store, - ) - .await - .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) - }; - let increment_webhook_outgoing_received_count = |merchant_id: String| { - metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( - &metrics::CONTEXT, - 1, - &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], - ) - }; - let success_response_handler = - |state: SessionState, - merchant_id: String, - process_tracker: Option, - business_status: &'static str| async move { - increment_webhook_outgoing_received_count(merchant_id); - - match process_tracker { - Some(process_tracker) => state - .store - .as_scheduler() - .finish_process_with_business_status(process_tracker, business_status.into()) - .await - .change_context( - errors::WebhooksFlowError::OutgoingWebhookProcessTrackerTaskUpdateFailed, - ), - None => Ok(()), - } - }; - let error_response_handler = |merchant_id: String, - delivery_attempt: enums::WebhookDeliveryAttempt, - status_code: u16, - log_message: &'static str| { - metrics::WEBHOOK_OUTGOING_NOT_RECEIVED_COUNT.add( - &metrics::CONTEXT, - 1, - &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], - ); - - let error = report!(errors::WebhooksFlowError::NotReceivedByMerchant); - logger::warn!(?error, ?delivery_attempt, ?status_code, %log_message); - }; - - match delivery_attempt { - enums::WebhookDeliveryAttempt::InitialAttempt => match response { - Err(client_error) => { - api_client_error_handler( - state.clone(), - merchant_key_store.clone(), - business_profile.merchant_id.clone(), - event_id.clone(), - client_error, - delivery_attempt, - ) - .await? - } - Ok(response) => { - let status_code = response.status(); - let _updated_event = update_event_in_storage( - state.clone(), - merchant_key_store.clone(), - business_profile.merchant_id.clone(), - event_id.clone(), - response, - ) - .await?; - - if status_code.is_success() { - success_response_handler( - state.clone(), - business_profile.merchant_id, - process_tracker, - "INITIAL_DELIVERY_ATTEMPT_SUCCESSFUL", - ) - .await?; - } else { - error_response_handler( - business_profile.merchant_id, - delivery_attempt, - status_code.as_u16(), - "Ignoring error when sending webhook to merchant", - ); - } - } - }, - enums::WebhookDeliveryAttempt::AutomaticRetry => { - let process_tracker = process_tracker - .get_required_value("process_tracker") - .change_context(errors::WebhooksFlowError::OutgoingWebhookRetrySchedulingFailed) - .attach_printable("`process_tracker` is unavailable in automatic retry flow")?; - match response { - Err(client_error) => { - api_client_error_handler( - state.clone(), - merchant_key_store.clone(), - business_profile.merchant_id.clone(), - event_id.clone(), - client_error, - delivery_attempt, - ) - .await?; - // Schedule a retry attempt for webhook delivery - outgoing_webhook_retry::retry_webhook_delivery_task( - &*state.store, - &business_profile.merchant_id, - process_tracker, - ) - .await - .change_context( - errors::WebhooksFlowError::OutgoingWebhookRetrySchedulingFailed, - )?; - } - Ok(response) => { - let status_code = response.status(); - let _updated_event = update_event_in_storage( - state.clone(), - merchant_key_store.clone(), - business_profile.merchant_id.clone(), - event_id.clone(), - response, - ) - .await?; - - if status_code.is_success() { - success_response_handler( - state.clone(), - business_profile.merchant_id, - Some(process_tracker), - "COMPLETED_BY_PT", - ) - .await?; - } else { - error_response_handler( - business_profile.merchant_id.clone(), - delivery_attempt, - status_code.as_u16(), - "An error occurred when sending webhook to merchant", - ); - // Schedule a retry attempt for webhook delivery - outgoing_webhook_retry::retry_webhook_delivery_task( - &*state.store, - &business_profile.merchant_id, - process_tracker, - ) - .await - .change_context( - errors::WebhooksFlowError::OutgoingWebhookRetrySchedulingFailed, - )?; - } - } - } - } - enums::WebhookDeliveryAttempt::ManualRetry => match response { - Err(client_error) => { - api_client_error_handler( - state.clone(), - merchant_key_store.clone(), - business_profile.merchant_id.clone(), - event_id.clone(), - client_error, - delivery_attempt, - ) - .await? - } - Ok(response) => { - let status_code = response.status(); - let _updated_event = update_event_in_storage( - state.clone(), - merchant_key_store.clone(), - business_profile.merchant_id.clone(), - event_id.clone(), - response, - ) - .await?; - - if status_code.is_success() { - increment_webhook_outgoing_received_count(business_profile.merchant_id.clone()); - } else { - error_response_handler( - business_profile.merchant_id, - delivery_attempt, - status_code.as_u16(), - "Ignoring error when sending webhook to merchant", - ); - } - } - }, - } - - Ok(()) -} - -fn raise_webhooks_analytics_event( - state: SessionState, - trigger_webhook_result: CustomResult<(), errors::WebhooksFlowError>, - content: Option, - merchant_id: String, - event: domain::Event, -) { - let error = if let Err(error) = trigger_webhook_result { - logger::error!(?error, "Failed to send webhook to merchant"); - - serde_json::to_value(error.current_context()) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .map_err(|error| { - logger::error!(?error, "Failed to serialize outgoing webhook error as JSON"); - error - }) - .ok() - } else { - None - }; - - let outgoing_webhook_event_content = content - .as_ref() - .and_then(api::OutgoingWebhookContent::get_outgoing_webhook_event_content); - let webhook_event = OutgoingWebhookEvent::new( - merchant_id, - event.event_id, - event.event_type, - outgoing_webhook_event_content, - error, - event.initial_attempt_id, - ); - state.event_handler().log_event(&webhook_event); -} - -#[allow(clippy::too_many_arguments)] -pub async fn webhooks_wrapper( - flow: &impl router_env::types::FlowMetric, - state: SessionState, - req_state: ReqState, - req: &actix_web::HttpRequest, - merchant_account: domain::MerchantAccount, - key_store: domain::MerchantKeyStore, - connector_name_or_mca_id: &str, - body: actix_web::web::Bytes, -) -> RouterResponse { - let start_instant = Instant::now(); - let (application_response, webhooks_response_tracker, serialized_req) = - Box::pin(webhooks_core::( - state.clone(), - req_state, - req, - merchant_account.clone(), - key_store, - connector_name_or_mca_id, - body.clone(), - )) - .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 - .attach_printable("Unable to extract request id from request") - .change_context(errors::ApiErrorResponse::InternalServerError)?; - let auth_type = auth::AuthenticationType::WebhookAuth { - merchant_id: merchant_account.merchant_id.clone(), - }; - let status_code = 200; - let api_event = ApiEventsType::Webhooks { - connector: connector_name_or_mca_id.to_string(), - payment_id: webhooks_response_tracker.get_payment_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 api_event = ApiEvent::new( - Some(merchant_account.merchant_id.clone()), - flow, - &request_id, - request_duration, - status_code, - serialized_req, - Some(response_value), - None, - auth_type, - None, - api_event, - req, - req.method(), - ); - state.event_handler().log_event(&api_event); - Ok(application_response) -} - -#[instrument(skip_all)] -pub async fn webhooks_core( - state: SessionState, - req_state: ReqState, - req: &actix_web::HttpRequest, - merchant_account: domain::MerchantAccount, - key_store: domain::MerchantKeyStore, - connector_name_or_mca_id: &str, - body: actix_web::web::Bytes, -) -> errors::RouterResult<( - services::ApplicationResponse, - WebhookResponseTracker, - serde_json::Value, -)> { - metrics::WEBHOOK_INCOMING_COUNT.add( - &metrics::CONTEXT, - 1, - &[metrics::KeyValue::new( - MERCHANT_ID, - merchant_account.merchant_id.clone(), - )], - ); - let mut request_details = api::IncomingWebhookRequestDetails { - method: req.method().clone(), - uri: req.uri().clone(), - headers: req.headers(), - query_params: req.query_string().to_string(), - body: &body, - }; - - // Fetch the merchant connector account to get the webhooks source secret - // `webhooks source secret` is a secret shared between the merchant and connector - // This is used for source verification and webhooks integrity - let (merchant_connector_account, connector, connector_name) = fetch_optional_mca_and_connector( - &state, - &merchant_account, - connector_name_or_mca_id, - &key_store, - ) - .await?; - - let decoded_body = connector - .decode_webhook_body( - &*state.clone().store, - &request_details, - &merchant_account.merchant_id, - ) - .await - .switch() - .attach_printable("There was an error in incoming webhook body decoding")?; - - request_details.body = &decoded_body; - - let event_type = match connector - .get_webhook_event_type(&request_details) - .allow_webhook_event_type_not_found( - state - .clone() - .conf - .webhooks - .ignore_error - .event_type - .unwrap_or(true), - ) - .switch() - .attach_printable("Could not find event type in incoming webhook body")? - { - Some(event_type) => event_type, - // Early return allows us to acknowledge the webhooks that we do not support - None => { - logger::error!( - webhook_payload =? request_details.body, - "Failed while identifying the event type", - ); - - metrics::WEBHOOK_EVENT_TYPE_IDENTIFICATION_FAILURE_COUNT.add( - &metrics::CONTEXT, - 1, - &[ - metrics::KeyValue::new(MERCHANT_ID, merchant_account.merchant_id.clone()), - metrics::KeyValue::new("connector", connector_name.to_string()), - ], - ); - - let response = connector - .get_webhook_api_response(&request_details) - .switch() - .attach_printable("Failed while early return in case of event type parsing")?; - - return Ok(( - response, - WebhookResponseTracker::NoEffect, - serde_json::Value::Null, - )); - } - }; - logger::info!(event_type=?event_type); - - let is_webhook_event_supported = !matches!( - event_type, - webhooks::IncomingWebhookEvent::EventNotSupported - ); - let is_webhook_event_enabled = !utils::is_webhook_event_disabled( - &*state.clone().store, - connector_name.as_str(), - &merchant_account.merchant_id, - &event_type, - ) - .await; - - //process webhook further only if webhook event is enabled and is not event_not_supported - let process_webhook_further = is_webhook_event_enabled && is_webhook_event_supported; - - logger::info!(process_webhook=?process_webhook_further); - - let flow_type: api::WebhookFlow = event_type.into(); - let mut event_object: Box = Box::new(serde_json::Value::Null); - let webhook_effect = if process_webhook_further - && !matches!(flow_type, api::WebhookFlow::ReturnResponse) - { - let object_ref_id = connector - .get_webhook_object_reference_id(&request_details) - .switch() - .attach_printable("Could not find object reference id in incoming webhook body")?; - let connector_enum = api_models::enums::Connector::from_str(&connector_name) - .change_context(errors::ApiErrorResponse::InvalidDataValue { - field_name: "connector", - }) - .attach_printable_lazy(|| { - format!("unable to parse connector name {connector_name:?}") - })?; - let connectors_with_source_verification_call = &state.conf.webhook_source_verification_call; - - let merchant_connector_account = match merchant_connector_account { - Some(merchant_connector_account) => merchant_connector_account, - None => { - helper_utils::get_mca_from_object_reference_id( - &*state.clone().store, - object_ref_id.clone(), - &merchant_account, - &connector_name, - &key_store, - ) - .await? - } - }; - - let source_verified = if connectors_with_source_verification_call - .connectors_with_webhook_source_verification_call - .contains(&connector_enum) - { - connector - .verify_webhook_source_verification_call( - &state, - &merchant_account, - merchant_connector_account.clone(), - &connector_name, - &request_details, - ) - .await - .or_else(|error| match error.current_context() { - errors::ConnectorError::WebhookSourceVerificationFailed => { - logger::error!(?error, "Source Verification Failed"); - Ok(false) - } - _ => Err(error), - }) - .switch() - .attach_printable("There was an issue in incoming webhook source verification")? - } else { - connector - .verify_webhook_source( - &request_details, - &merchant_account, - merchant_connector_account.clone(), - connector_name.as_str(), - ) - .await - .or_else(|error| match error.current_context() { - errors::ConnectorError::WebhookSourceVerificationFailed => { - logger::error!(?error, "Source Verification Failed"); - Ok(false) - } - _ => Err(error), - }) - .switch() - .attach_printable("There was an issue in incoming webhook source verification")? - }; - - if source_verified { - metrics::WEBHOOK_SOURCE_VERIFIED_COUNT.add( - &metrics::CONTEXT, - 1, - &[metrics::KeyValue::new( - MERCHANT_ID, - merchant_account.merchant_id.clone(), - )], - ); - } else if connector.is_webhook_source_verification_mandatory() { - // if webhook consumption is mandatory for connector, fail webhook - // so that merchant can retrigger it after updating merchant_secret - return Err(errors::ApiErrorResponse::WebhookAuthenticationFailed.into()); - } - - logger::info!(source_verified=?source_verified); - - event_object = connector - .get_webhook_resource_object(&request_details) - .switch() - .attach_printable("Could not find resource object in incoming webhook body")?; - - let webhook_details = api::IncomingWebhookDetails { - object_reference_id: object_ref_id.clone(), - resource_object: serde_json::to_vec(&event_object) - .change_context(errors::ParsingError::EncodeError("byte-vec")) - .attach_printable("Unable to convert webhook payload to a value") - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable( - "There was an issue when encoding the incoming webhook body to bytes", - )?, - }; - - let profile_id = merchant_connector_account - .profile_id - .as_ref() - .get_required_value("profile_id") - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Could not find profile_id in merchant connector account")?; - - let business_profile = state - .store - .find_business_profile_by_profile_id(profile_id) - .await - .to_not_found_response(errors::ApiErrorResponse::BusinessProfileNotFound { - id: profile_id.to_string(), - })?; - - match flow_type { - api::WebhookFlow::Payment => Box::pin(payments_incoming_webhook_flow( - state.clone(), - req_state, - merchant_account, - business_profile, - key_store, - webhook_details, - source_verified, - )) - .await - .attach_printable("Incoming webhook flow for payments failed")?, - - api::WebhookFlow::Refund => Box::pin(refunds_incoming_webhook_flow( - state.clone(), - merchant_account, - business_profile, - key_store, - webhook_details, - connector_name.as_str(), - source_verified, - event_type, - )) - .await - .attach_printable("Incoming webhook flow for refunds failed")?, - - api::WebhookFlow::Dispute => Box::pin(disputes_incoming_webhook_flow( - state.clone(), - merchant_account, - business_profile, - key_store, - webhook_details, - source_verified, - connector, - &request_details, - event_type, - )) - .await - .attach_printable("Incoming webhook flow for disputes failed")?, - - api::WebhookFlow::BankTransfer => Box::pin(bank_transfer_webhook_flow( - state.clone(), - req_state, - merchant_account, - business_profile, - key_store, - webhook_details, - source_verified, - )) - .await - .attach_printable("Incoming bank-transfer webhook flow failed")?, - - api::WebhookFlow::ReturnResponse => WebhookResponseTracker::NoEffect, - - api::WebhookFlow::Mandate => Box::pin(mandates_incoming_webhook_flow( - state.clone(), - merchant_account, - business_profile, - key_store, - webhook_details, - source_verified, - event_type, - )) - .await - .attach_printable("Incoming webhook flow for mandates failed")?, - - api::WebhookFlow::ExternalAuthentication => { - Box::pin(external_authentication_incoming_webhook_flow( - state.clone(), - req_state, - merchant_account, - key_store, - source_verified, - event_type, - &request_details, - connector, - object_ref_id, - business_profile, - merchant_connector_account, - )) - .await - .attach_printable("Incoming webhook flow for external authentication failed")? - } - api::WebhookFlow::FraudCheck => Box::pin(frm_incoming_webhook_flow( - state.clone(), - req_state, - merchant_account, - key_store, - source_verified, - event_type, - object_ref_id, - business_profile, - )) - .await - .attach_printable("Incoming webhook flow for fraud check failed")?, - - #[cfg(feature = "payouts")] - api::WebhookFlow::Payout => Box::pin(payouts_incoming_webhook_flow( - state.clone(), - merchant_account, - business_profile, - key_store, - webhook_details, - event_type, - source_verified, - )) - .await - .attach_printable("Incoming webhook flow for payouts failed")?, - - _ => Err(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Unsupported Flow Type received in incoming webhooks")?, - } - } else { - metrics::WEBHOOK_INCOMING_FILTERED_COUNT.add( - &metrics::CONTEXT, - 1, - &[metrics::KeyValue::new( - MERCHANT_ID, - merchant_account.merchant_id.clone(), - )], - ); - WebhookResponseTracker::NoEffect - }; - - let response = connector - .get_webhook_api_response(&request_details) - .switch() - .attach_printable("Could not get incoming webhook api response from connector")?; - - let serialized_request = event_object - .masked_serialize() - .change_context(errors::ApiErrorResponse::InternalServerError) - .attach_printable("Could not convert webhook effect to string")?; - Ok((response, webhook_effect, serialized_request)) -} - -#[inline] -pub async fn get_payment_id( - db: &dyn StorageInterface, - payment_id: &api::PaymentIdType, - merchant_id: &str, - storage_scheme: enums::MerchantStorageScheme, -) -> errors::RouterResult { - let pay_id = || async { - match payment_id { - api_models::payments::PaymentIdType::PaymentIntentId(ref id) => Ok(id.to_string()), - api_models::payments::PaymentIdType::ConnectorTransactionId(ref id) => db - .find_payment_attempt_by_merchant_id_connector_txn_id( - merchant_id, - id, - storage_scheme, - ) - .await - .map(|p| p.payment_id), - api_models::payments::PaymentIdType::PaymentAttemptId(ref id) => db - .find_payment_attempt_by_attempt_id_merchant_id(id, merchant_id, storage_scheme) - .await - .map(|p| p.payment_id), - api_models::payments::PaymentIdType::PreprocessingId(ref id) => db - .find_payment_attempt_by_preprocessing_id_merchant_id( - id, - merchant_id, - storage_scheme, - ) - .await - .map(|p| p.payment_id), - } - }; - - pay_id() - .await - .to_not_found_response(errors::ApiErrorResponse::PaymentNotFound) -} - -fn get_connector_by_connector_name( - state: &SessionState, - connector_name: &str, - merchant_connector_id: Option, -) -> CustomResult<(&'static (dyn api::Connector + Sync), String), errors::ApiErrorResponse> { - let authentication_connector = - api_models::enums::convert_authentication_connector(connector_name); - #[cfg(feature = "frm")] - { - let frm_connector = api_models::enums::convert_frm_connector(connector_name); - if frm_connector.is_some() { - let frm_connector_data = - api::FraudCheckConnectorData::get_connector_by_name(connector_name)?; - return Ok(( - *frm_connector_data.connector, - frm_connector_data.connector_name.to_string(), - )); - } - } - - let (connector, connector_name) = if authentication_connector.is_some() { - let authentication_connector_data = - api::AuthenticationConnectorData::get_connector_by_name(connector_name)?; - ( - authentication_connector_data.connector, - authentication_connector_data.connector_name.to_string(), - ) - } else { - let connector_data = api::ConnectorData::get_connector_by_name( - &state.conf.connectors, - connector_name, - api::GetToken::Connector, - merchant_connector_id, - ) - .change_context(errors::ApiErrorResponse::InvalidRequestData { - message: "invalid connector name received".to_string(), - }) - .attach_printable("Failed construction of ConnectorData")?; - ( - connector_data.connector, - connector_data.connector_name.to_string(), - ) - }; - Ok((*connector, connector_name)) -} - -/// This function fetches the merchant connector account ( if the url used is /{merchant_connector_id}) -/// if merchant connector id is not passed in the request, then this will return None for mca -async fn fetch_optional_mca_and_connector( - state: &SessionState, - merchant_account: &domain::MerchantAccount, - connector_name_or_mca_id: &str, - key_store: &domain::MerchantKeyStore, -) -> CustomResult< - ( - Option, - &'static (dyn api::Connector + Sync), - String, - ), - errors::ApiErrorResponse, -> { - let db = &state.store; - if connector_name_or_mca_id.starts_with("mca_") { - let mca = db - .find_by_merchant_connector_account_merchant_id_merchant_connector_id( - &merchant_account.merchant_id, - connector_name_or_mca_id, - key_store, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { - id: connector_name_or_mca_id.to_string(), - }) - .attach_printable( - "error while fetching merchant_connector_account from connector_id", - )?; - let (connector, connector_name) = get_connector_by_connector_name( - state, - &mca.connector_name, - Some(mca.merchant_connector_id.clone()), - )?; - - Ok((Some(mca), connector, connector_name)) - } else { - // Merchant connector account is already being queried, it is safe to set connector id as None - let (connector, connector_name) = - get_connector_by_connector_name(state, connector_name_or_mca_id, None)?; - Ok((None, connector, connector_name)) - } -} - -pub async fn add_outgoing_webhook_retry_task_to_process_tracker( - db: &dyn StorageInterface, - business_profile: &diesel_models::business_profile::BusinessProfile, - event: &domain::Event, -) -> CustomResult { - let schedule_time = outgoing_webhook_retry::get_webhook_delivery_retry_schedule_time( - db, - &business_profile.merchant_id, - 0, - ) - .await - .ok_or(errors::StorageError::ValueNotFound( - "Process tracker schedule time".into(), // Can raise a better error here - )) - .attach_printable("Failed to obtain initial process tracker schedule time")?; - - let tracking_data = types::OutgoingWebhookTrackingData { - merchant_id: business_profile.merchant_id.clone(), - business_profile_id: business_profile.profile_id.clone(), - event_type: event.event_type, - event_class: event.event_class, - primary_object_id: event.primary_object_id.clone(), - primary_object_type: event.primary_object_type, - initial_attempt_id: event.initial_attempt_id.clone(), - }; - - let runner = storage::ProcessTrackerRunner::OutgoingWebhookRetryWorkflow; - let task = "OUTGOING_WEBHOOK_RETRY"; - let tag = ["OUTGOING_WEBHOOKS"]; - let process_tracker_id = scheduler::utils::get_process_tracker_id( - runner, - task, - &event.event_id, - &business_profile.merchant_id, - ); - let process_tracker_entry = storage::ProcessTrackerNew::new( - process_tracker_id, - task, - runner, - tag, - tracking_data, - schedule_time, - ) - .map_err(errors::StorageError::from)?; - - match db.insert_process(process_tracker_entry).await { - Ok(process_tracker) => { - crate::routes::metrics::TASKS_ADDED_COUNT.add( - &metrics::CONTEXT, - 1, - &[add_attributes("flow", "OutgoingWebhookRetry")], - ); - Ok(process_tracker) - } - Err(error) => { - crate::routes::metrics::TASK_ADDITION_FAILURES_COUNT.add( - &metrics::CONTEXT, - 1, - &[add_attributes("flow", "OutgoingWebhookRetry")], - ); - Err(error) - } - } -} - -fn get_webhook_url_from_business_profile( - business_profile: &diesel_models::business_profile::BusinessProfile, -) -> CustomResult { - let webhook_details_json = business_profile - .webhook_details - .clone() - .get_required_value("webhook_details") - .change_context(errors::WebhooksFlowError::MerchantWebhookDetailsNotFound)?; - - let webhook_details: api::WebhookDetails = - webhook_details_json - .parse_value("WebhookDetails") - .change_context(errors::WebhooksFlowError::MerchantWebhookDetailsNotFound)?; - - webhook_details - .webhook_url - .get_required_value("webhook_url") - .change_context(errors::WebhooksFlowError::MerchantWebhookUrlNotConfigured) - .map(ExposeInterface::expose) -} - -pub(crate) fn get_outgoing_webhook_request( - merchant_account: &domain::MerchantAccount, - outgoing_webhook: api::OutgoingWebhook, - payment_response_hash_key: Option<&str>, -) -> CustomResult { - #[inline] - fn get_outgoing_webhook_request_inner( - outgoing_webhook: api::OutgoingWebhook, - payment_response_hash_key: Option<&str>, - ) -> CustomResult { - let mut headers = vec![( - reqwest::header::CONTENT_TYPE.to_string(), - mime::APPLICATION_JSON.essence_str().into(), - )]; - - let transformed_outgoing_webhook = WebhookType::from(outgoing_webhook); - - let outgoing_webhooks_signature = transformed_outgoing_webhook - .get_outgoing_webhooks_signature(payment_response_hash_key)?; - - if let Some(signature) = outgoing_webhooks_signature.signature { - WebhookType::add_webhook_header(&mut headers, signature) - } - - Ok(OutgoingWebhookRequestContent { - body: outgoing_webhooks_signature.payload, - headers: headers - .into_iter() - .map(|(name, value)| (name, Secret::new(value.into_inner()))) - .collect(), - }) - } - - match merchant_account.get_compatible_connector() { - #[cfg(feature = "stripe")] - Some(api_models::enums::Connector::Stripe) => get_outgoing_webhook_request_inner::< - stripe_webhooks::StripeOutgoingWebhook, - >( - outgoing_webhook, payment_response_hash_key - ), - _ => get_outgoing_webhook_request_inner::( - outgoing_webhook, - payment_response_hash_key, - ), - } -} diff --git a/crates/router/src/core/webhooks/incoming.rs b/crates/router/src/core/webhooks/incoming.rs new file mode 100644 index 000000000000..2f1a37981ec6 --- /dev/null +++ b/crates/router/src/core/webhooks/incoming.rs @@ -0,0 +1,1628 @@ +use std::{str::FromStr, time::Instant}; + +use actix_web::FromRequest; +#[cfg(feature = "payouts")] +use api_models::payouts as payout_models; +use api_models::{ + payments::HeaderPayload, + webhooks::{self, WebhookResponseTracker}, +}; +use common_utils::{errors::ReportSwitchExt, events::ApiEventsType}; +use error_stack::{report, ResultExt}; +use masking::ExposeInterface; +use router_env::{instrument, tracing, tracing_actix_web::RequestId}; + +use super::{types, utils, MERCHANT_ID}; +use crate::{ + consts, + core::{ + api_locking, + errors::{self, ConnectorErrorExt, CustomResult, RouterResponse, StorageErrorExt}, + metrics, payments, refunds, utils as core_utils, + }, + db::StorageInterface, + events::api_logs::ApiEvent, + logger, + routes::{ + app::{ReqState, SessionStateInfo}, + lock_utils, + metrics::request::add_attributes, + SessionState, + }, + services::{self, authentication as auth}, + types::{ + api::{self, mandates::MandateResponseExt}, + domain, + storage::{self, enums}, + transformers::{ForeignFrom, ForeignInto, ForeignTryFrom}, + }, + utils::{self as helper_utils, generate_id, OptionExt}, +}; +#[cfg(feature = "payouts")] +use crate::{core::payouts, types::storage::PayoutAttemptUpdate}; + +#[allow(clippy::too_many_arguments)] +pub async fn incoming_webhooks_wrapper( + flow: &impl router_env::types::FlowMetric, + state: SessionState, + req_state: ReqState, + req: &actix_web::HttpRequest, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + connector_name_or_mca_id: &str, + body: actix_web::web::Bytes, +) -> RouterResponse { + let start_instant = Instant::now(); + let (application_response, webhooks_response_tracker, serialized_req) = + Box::pin(incoming_webhooks_core::( + state.clone(), + req_state, + req, + merchant_account.clone(), + key_store, + connector_name_or_mca_id, + body.clone(), + )) + .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 + .attach_printable("Unable to extract request id from request") + .change_context(errors::ApiErrorResponse::InternalServerError)?; + let auth_type = auth::AuthenticationType::WebhookAuth { + merchant_id: merchant_account.merchant_id.clone(), + }; + let status_code = 200; + let api_event = ApiEventsType::Webhooks { + connector: connector_name_or_mca_id.to_string(), + payment_id: webhooks_response_tracker.get_payment_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 api_event = ApiEvent::new( + Some(merchant_account.merchant_id.clone()), + flow, + &request_id, + request_duration, + status_code, + serialized_req, + Some(response_value), + None, + auth_type, + None, + api_event, + req, + req.method(), + ); + state.event_handler().log_event(&api_event); + Ok(application_response) +} + +#[instrument(skip_all)] +async fn incoming_webhooks_core( + state: SessionState, + req_state: ReqState, + req: &actix_web::HttpRequest, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + connector_name_or_mca_id: &str, + body: actix_web::web::Bytes, +) -> errors::RouterResult<( + services::ApplicationResponse, + WebhookResponseTracker, + serde_json::Value, +)> { + metrics::WEBHOOK_INCOMING_COUNT.add( + &metrics::CONTEXT, + 1, + &[metrics::KeyValue::new( + MERCHANT_ID, + merchant_account.merchant_id.clone(), + )], + ); + let mut request_details = api::IncomingWebhookRequestDetails { + method: req.method().clone(), + uri: req.uri().clone(), + headers: req.headers(), + query_params: req.query_string().to_string(), + body: &body, + }; + + // Fetch the merchant connector account to get the webhooks source secret + // `webhooks source secret` is a secret shared between the merchant and connector + // This is used for source verification and webhooks integrity + let (merchant_connector_account, connector, connector_name) = fetch_optional_mca_and_connector( + &state, + &merchant_account, + connector_name_or_mca_id, + &key_store, + ) + .await?; + + let decoded_body = connector + .decode_webhook_body( + &*state.clone().store, + &request_details, + &merchant_account.merchant_id, + ) + .await + .switch() + .attach_printable("There was an error in incoming webhook body decoding")?; + + request_details.body = &decoded_body; + + let event_type = match connector + .get_webhook_event_type(&request_details) + .allow_webhook_event_type_not_found( + state + .clone() + .conf + .webhooks + .ignore_error + .event_type + .unwrap_or(true), + ) + .switch() + .attach_printable("Could not find event type in incoming webhook body")? + { + Some(event_type) => event_type, + // Early return allows us to acknowledge the webhooks that we do not support + None => { + logger::error!( + webhook_payload =? request_details.body, + "Failed while identifying the event type", + ); + + metrics::WEBHOOK_EVENT_TYPE_IDENTIFICATION_FAILURE_COUNT.add( + &metrics::CONTEXT, + 1, + &[ + metrics::KeyValue::new(MERCHANT_ID, merchant_account.merchant_id.clone()), + metrics::KeyValue::new("connector", connector_name.to_string()), + ], + ); + + let response = connector + .get_webhook_api_response(&request_details) + .switch() + .attach_printable("Failed while early return in case of event type parsing")?; + + return Ok(( + response, + WebhookResponseTracker::NoEffect, + serde_json::Value::Null, + )); + } + }; + logger::info!(event_type=?event_type); + + let is_webhook_event_supported = !matches!( + event_type, + webhooks::IncomingWebhookEvent::EventNotSupported + ); + let is_webhook_event_enabled = !utils::is_webhook_event_disabled( + &*state.clone().store, + connector_name.as_str(), + &merchant_account.merchant_id, + &event_type, + ) + .await; + + //process webhook further only if webhook event is enabled and is not event_not_supported + let process_webhook_further = is_webhook_event_enabled && is_webhook_event_supported; + + logger::info!(process_webhook=?process_webhook_further); + + let flow_type: api::WebhookFlow = event_type.into(); + let mut event_object: Box = Box::new(serde_json::Value::Null); + let webhook_effect = if process_webhook_further + && !matches!(flow_type, api::WebhookFlow::ReturnResponse) + { + let object_ref_id = connector + .get_webhook_object_reference_id(&request_details) + .switch() + .attach_printable("Could not find object reference id in incoming webhook body")?; + let connector_enum = api_models::enums::Connector::from_str(&connector_name) + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "connector", + }) + .attach_printable_lazy(|| { + format!("unable to parse connector name {connector_name:?}") + })?; + let connectors_with_source_verification_call = &state.conf.webhook_source_verification_call; + + let merchant_connector_account = match merchant_connector_account { + Some(merchant_connector_account) => merchant_connector_account, + None => { + helper_utils::get_mca_from_object_reference_id( + &*state.clone().store, + object_ref_id.clone(), + &merchant_account, + &connector_name, + &key_store, + ) + .await? + } + }; + + let source_verified = if connectors_with_source_verification_call + .connectors_with_webhook_source_verification_call + .contains(&connector_enum) + { + connector + .verify_webhook_source_verification_call( + &state, + &merchant_account, + merchant_connector_account.clone(), + &connector_name, + &request_details, + ) + .await + .or_else(|error| match error.current_context() { + errors::ConnectorError::WebhookSourceVerificationFailed => { + logger::error!(?error, "Source Verification Failed"); + Ok(false) + } + _ => Err(error), + }) + .switch() + .attach_printable("There was an issue in incoming webhook source verification")? + } else { + connector + .verify_webhook_source( + &request_details, + &merchant_account, + merchant_connector_account.clone(), + connector_name.as_str(), + ) + .await + .or_else(|error| match error.current_context() { + errors::ConnectorError::WebhookSourceVerificationFailed => { + logger::error!(?error, "Source Verification Failed"); + Ok(false) + } + _ => Err(error), + }) + .switch() + .attach_printable("There was an issue in incoming webhook source verification")? + }; + + if source_verified { + metrics::WEBHOOK_SOURCE_VERIFIED_COUNT.add( + &metrics::CONTEXT, + 1, + &[metrics::KeyValue::new( + MERCHANT_ID, + merchant_account.merchant_id.clone(), + )], + ); + } else if connector.is_webhook_source_verification_mandatory() { + // if webhook consumption is mandatory for connector, fail webhook + // so that merchant can retrigger it after updating merchant_secret + return Err(errors::ApiErrorResponse::WebhookAuthenticationFailed.into()); + } + + logger::info!(source_verified=?source_verified); + + event_object = connector + .get_webhook_resource_object(&request_details) + .switch() + .attach_printable("Could not find resource object in incoming webhook body")?; + + let webhook_details = api::IncomingWebhookDetails { + object_reference_id: object_ref_id.clone(), + resource_object: serde_json::to_vec(&event_object) + .change_context(errors::ParsingError::EncodeError("byte-vec")) + .attach_printable("Unable to convert webhook payload to a value") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable( + "There was an issue when encoding the incoming webhook body to bytes", + )?, + }; + + let profile_id = merchant_connector_account + .profile_id + .as_ref() + .get_required_value("profile_id") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Could not find profile_id in merchant connector account")?; + + let business_profile = state + .store + .find_business_profile_by_profile_id(profile_id) + .await + .to_not_found_response(errors::ApiErrorResponse::BusinessProfileNotFound { + id: profile_id.to_string(), + })?; + + match flow_type { + api::WebhookFlow::Payment => Box::pin(payments_incoming_webhook_flow( + state.clone(), + req_state, + merchant_account, + business_profile, + key_store, + webhook_details, + source_verified, + )) + .await + .attach_printable("Incoming webhook flow for payments failed")?, + + api::WebhookFlow::Refund => Box::pin(refunds_incoming_webhook_flow( + state.clone(), + merchant_account, + business_profile, + key_store, + webhook_details, + connector_name.as_str(), + source_verified, + event_type, + )) + .await + .attach_printable("Incoming webhook flow for refunds failed")?, + + api::WebhookFlow::Dispute => Box::pin(disputes_incoming_webhook_flow( + state.clone(), + merchant_account, + business_profile, + key_store, + webhook_details, + source_verified, + connector, + &request_details, + event_type, + )) + .await + .attach_printable("Incoming webhook flow for disputes failed")?, + + api::WebhookFlow::BankTransfer => Box::pin(bank_transfer_webhook_flow( + state.clone(), + req_state, + merchant_account, + business_profile, + key_store, + webhook_details, + source_verified, + )) + .await + .attach_printable("Incoming bank-transfer webhook flow failed")?, + + api::WebhookFlow::ReturnResponse => WebhookResponseTracker::NoEffect, + + api::WebhookFlow::Mandate => Box::pin(mandates_incoming_webhook_flow( + state.clone(), + merchant_account, + business_profile, + key_store, + webhook_details, + source_verified, + event_type, + )) + .await + .attach_printable("Incoming webhook flow for mandates failed")?, + + api::WebhookFlow::ExternalAuthentication => { + Box::pin(external_authentication_incoming_webhook_flow( + state.clone(), + req_state, + merchant_account, + key_store, + source_verified, + event_type, + &request_details, + connector, + object_ref_id, + business_profile, + merchant_connector_account, + )) + .await + .attach_printable("Incoming webhook flow for external authentication failed")? + } + api::WebhookFlow::FraudCheck => Box::pin(frm_incoming_webhook_flow( + state.clone(), + req_state, + merchant_account, + key_store, + source_verified, + event_type, + object_ref_id, + business_profile, + )) + .await + .attach_printable("Incoming webhook flow for fraud check failed")?, + + #[cfg(feature = "payouts")] + api::WebhookFlow::Payout => Box::pin(payouts_incoming_webhook_flow( + state.clone(), + merchant_account, + business_profile, + key_store, + webhook_details, + event_type, + source_verified, + )) + .await + .attach_printable("Incoming webhook flow for payouts failed")?, + + _ => Err(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Unsupported Flow Type received in incoming webhooks")?, + } + } else { + metrics::WEBHOOK_INCOMING_FILTERED_COUNT.add( + &metrics::CONTEXT, + 1, + &[metrics::KeyValue::new( + MERCHANT_ID, + merchant_account.merchant_id.clone(), + )], + ); + WebhookResponseTracker::NoEffect + }; + + let response = connector + .get_webhook_api_response(&request_details) + .switch() + .attach_printable("Could not get incoming webhook api response from connector")?; + + let serialized_request = event_object + .masked_serialize() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Could not convert webhook effect to string")?; + Ok((response, webhook_effect, serialized_request)) +} + +#[instrument(skip_all)] +async fn payments_incoming_webhook_flow( + state: SessionState, + req_state: ReqState, + merchant_account: domain::MerchantAccount, + business_profile: diesel_models::business_profile::BusinessProfile, + key_store: domain::MerchantKeyStore, + webhook_details: api::IncomingWebhookDetails, + source_verified: bool, +) -> CustomResult { + let consume_or_trigger_flow = if source_verified { + payments::CallConnectorAction::HandleResponse(webhook_details.resource_object) + } else { + payments::CallConnectorAction::Trigger + }; + let payments_response = match webhook_details.object_reference_id { + webhooks::ObjectReferenceId::PaymentId(id) => { + let payment_id = get_payment_id( + state.store.as_ref(), + &id, + merchant_account.merchant_id.as_str(), + merchant_account.storage_scheme, + ) + .await?; + + let lock_action = api_locking::LockAction::Hold { + input: api_locking::LockingInput { + unique_locking_key: payment_id, + api_identifier: lock_utils::ApiIdentifier::Payments, + override_lock_retries: None, + }, + }; + + lock_action + .clone() + .perform_locking_action(&state, merchant_account.merchant_id.to_string()) + .await?; + + let response = Box::pin(payments::payments_core::< + api::PSync, + api::PaymentsResponse, + _, + _, + _, + >( + state.clone(), + req_state, + merchant_account.clone(), + key_store.clone(), + payments::operations::PaymentStatus, + api::PaymentsRetrieveRequest { + resource_id: id, + merchant_id: Some(merchant_account.merchant_id.clone()), + force_sync: true, + connector: None, + param: None, + merchant_connector_details: None, + client_secret: None, + expand_attempts: None, + expand_captures: None, + }, + services::AuthFlow::Merchant, + consume_or_trigger_flow, + None, + HeaderPayload::default(), + )) + .await; + + lock_action + .free_lock_action(&state, merchant_account.merchant_id.to_owned()) + .await?; + + match response { + Ok(value) => value, + Err(err) + if matches!( + err.current_context(), + &errors::ApiErrorResponse::PaymentNotFound + ) && state + .conf + .webhooks + .ignore_error + .payment_not_found + .unwrap_or(true) => + { + metrics::WEBHOOK_PAYMENT_NOT_FOUND.add( + &metrics::CONTEXT, + 1, + &[add_attributes( + "merchant_id", + merchant_account.merchant_id.clone(), + )], + ); + return Ok(WebhookResponseTracker::NoEffect); + } + error @ Err(_) => error?, + } + } + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( + "Did not get payment id as object reference id in webhook payments flow", + )?, + }; + + match payments_response { + services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { + let payment_id = payments_response + .payment_id + .clone() + .get_required_value("payment_id") + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("payment id not received from payments core")?; + + let status = payments_response.status; + + let event_type: Option = payments_response.status.foreign_into(); + + // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook + if let Some(outgoing_event_type) = event_type { + let primary_object_created_at = payments_response.created; + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Payments, + payment_id.clone(), + enums::EventObjectType::PaymentDetails, + api::OutgoingWebhookContent::PaymentDetails(payments_response), + primary_object_created_at, + ) + .await?; + }; + + let response = WebhookResponseTracker::Payment { payment_id, status }; + + Ok(response) + } + + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("received non-json response from payments core")?, + } +} + +#[cfg(feature = "payouts")] +#[instrument(skip_all)] +async fn payouts_incoming_webhook_flow( + state: SessionState, + merchant_account: domain::MerchantAccount, + business_profile: diesel_models::business_profile::BusinessProfile, + key_store: domain::MerchantKeyStore, + webhook_details: api::IncomingWebhookDetails, + event_type: webhooks::IncomingWebhookEvent, + source_verified: bool, +) -> CustomResult { + metrics::INCOMING_PAYOUT_WEBHOOK_METRIC.add(&metrics::CONTEXT, 1, &[]); + if source_verified { + let db = &*state.store; + //find payout_attempt by object_reference_id + let payout_attempt = match webhook_details.object_reference_id { + webhooks::ObjectReferenceId::PayoutId(payout_id_type) => match payout_id_type { + webhooks::PayoutIdType::PayoutAttemptId(id) => db + .find_payout_attempt_by_merchant_id_payout_attempt_id( + &merchant_account.merchant_id, + &id, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable("Failed to fetch the payout attempt")?, + webhooks::PayoutIdType::ConnectorPayoutId(id) => db + .find_payout_attempt_by_merchant_id_connector_payout_id( + &merchant_account.merchant_id, + &id, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable("Failed to fetch the payout attempt")?, + }, + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("received a non-payout id when processing payout webhooks")?, + }; + + let payouts = db + .find_payout_by_merchant_id_payout_id( + &merchant_account.merchant_id, + &payout_attempt.payout_id, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable("Failed to fetch the payout")?; + + let payout_attempt_update = PayoutAttemptUpdate::StatusUpdate { + connector_payout_id: payout_attempt.connector_payout_id.clone(), + status: common_enums::PayoutStatus::foreign_try_from(event_type) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("failed payout status mapping from event type")?, + error_message: None, + error_code: None, + is_eligible: payout_attempt.is_eligible, + }; + + let action_req = + payout_models::PayoutRequest::PayoutActionRequest(payout_models::PayoutActionRequest { + payout_id: payouts.payout_id.clone(), + }); + + let payout_data = + payouts::make_payout_data(&state, &merchant_account, &key_store, &action_req).await?; + + let updated_payout_attempt = db + .update_payout_attempt( + &payout_attempt, + payout_attempt_update, + &payout_data.payouts, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable_lazy(|| { + format!( + "Failed while updating payout attempt: payout_attempt_id: {}", + payout_attempt.payout_attempt_id + ) + })?; + + let event_type: Option = updated_payout_attempt.status.foreign_into(); + + // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook + if let Some(outgoing_event_type) = event_type { + let router_response = + payouts::response_handler(&merchant_account, &payout_data).await?; + + let payout_create_response: payout_models::PayoutCreateResponse = match router_response + { + services::ApplicationResponse::Json(response) => response, + _ => Err(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable("Failed to fetch the payout create response")?, + }; + + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Payouts, + updated_payout_attempt.payout_id.clone(), + enums::EventObjectType::PayoutDetails, + api::OutgoingWebhookContent::PayoutDetails(payout_create_response), + Some(updated_payout_attempt.created_at), + ) + .await?; + } + + Ok(WebhookResponseTracker::Payout { + payout_id: updated_payout_attempt.payout_id, + status: updated_payout_attempt.status, + }) + } else { + metrics::INCOMING_PAYOUT_WEBHOOK_SIGNATURE_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]); + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + } +} + +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +async fn refunds_incoming_webhook_flow( + state: SessionState, + merchant_account: domain::MerchantAccount, + business_profile: diesel_models::business_profile::BusinessProfile, + key_store: domain::MerchantKeyStore, + webhook_details: api::IncomingWebhookDetails, + connector_name: &str, + source_verified: bool, + event_type: webhooks::IncomingWebhookEvent, +) -> CustomResult { + let db = &*state.store; + //find refund by connector refund id + let refund = match webhook_details.object_reference_id { + webhooks::ObjectReferenceId::RefundId(refund_id_type) => match refund_id_type { + webhooks::RefundIdType::RefundId(id) => db + .find_refund_by_merchant_id_refund_id( + &merchant_account.merchant_id, + &id, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable("Failed to fetch the refund")?, + webhooks::RefundIdType::ConnectorRefundId(id) => db + .find_refund_by_merchant_id_connector_refund_id_connector( + &merchant_account.merchant_id, + &id, + connector_name, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable("Failed to fetch the refund")?, + }, + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("received a non-refund id when processing refund webhooks")?, + }; + let refund_id = refund.refund_id.to_owned(); + //if source verified then update refund status else trigger refund sync + let updated_refund = if source_verified { + let refund_update = storage::RefundUpdate::StatusUpdate { + connector_refund_id: None, + sent_to_gateway: true, + refund_status: common_enums::RefundStatus::foreign_try_from(event_type) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("failed refund status mapping from event type")?, + updated_by: merchant_account.storage_scheme.to_string(), + }; + db.update_refund( + refund.to_owned(), + refund_update, + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) + .attach_printable_lazy(|| format!("Failed while updating refund: refund_id: {refund_id}"))? + } else { + Box::pin(refunds::refund_retrieve_core( + state.clone(), + merchant_account.clone(), + key_store.clone(), + api_models::refunds::RefundsRetrieveRequest { + refund_id: refund_id.to_owned(), + force_sync: Some(true), + merchant_connector_details: None, + }, + )) + .await + .attach_printable_lazy(|| format!("Failed while updating refund: refund_id: {refund_id}"))? + }; + let event_type: Option = updated_refund.refund_status.foreign_into(); + + // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook + if let Some(outgoing_event_type) = event_type { + let refund_response: api_models::refunds::RefundResponse = + updated_refund.clone().foreign_into(); + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Refunds, + refund_id, + enums::EventObjectType::RefundDetails, + api::OutgoingWebhookContent::RefundDetails(refund_response), + Some(updated_refund.created_at), + ) + .await?; + } + + Ok(WebhookResponseTracker::Refund { + payment_id: updated_refund.payment_id, + refund_id: updated_refund.refund_id, + status: updated_refund.refund_status, + }) +} + +async fn get_payment_attempt_from_object_reference_id( + state: &SessionState, + object_reference_id: webhooks::ObjectReferenceId, + merchant_account: &domain::MerchantAccount, +) -> CustomResult< + hyperswitch_domain_models::payments::payment_attempt::PaymentAttempt, + errors::ApiErrorResponse, +> { + let db = &*state.store; + match object_reference_id { + api::ObjectReferenceId::PaymentId(api::PaymentIdType::ConnectorTransactionId(ref id)) => db + .find_payment_attempt_by_merchant_id_connector_txn_id( + &merchant_account.merchant_id, + id, + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), + api::ObjectReferenceId::PaymentId(api::PaymentIdType::PaymentAttemptId(ref id)) => db + .find_payment_attempt_by_attempt_id_merchant_id( + id, + &merchant_account.merchant_id, + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), + api::ObjectReferenceId::PaymentId(api::PaymentIdType::PreprocessingId(ref id)) => db + .find_payment_attempt_by_preprocessing_id_merchant_id( + id, + &merchant_account.merchant_id, + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("received a non-payment id for retrieving payment")?, + } +} + +#[allow(clippy::too_many_arguments)] +async fn get_or_update_dispute_object( + state: SessionState, + option_dispute: Option, + dispute_details: api::disputes::DisputePayload, + merchant_id: &str, + payment_attempt: &hyperswitch_domain_models::payments::payment_attempt::PaymentAttempt, + event_type: webhooks::IncomingWebhookEvent, + business_profile: &diesel_models::business_profile::BusinessProfile, + connector_name: &str, +) -> CustomResult { + let db = &*state.store; + match option_dispute { + None => { + metrics::INCOMING_DISPUTE_WEBHOOK_NEW_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]); + let dispute_id = generate_id(consts::ID_LENGTH, "dp"); + let new_dispute = diesel_models::dispute::DisputeNew { + dispute_id, + amount: dispute_details.amount.clone(), + currency: dispute_details.currency, + dispute_stage: dispute_details.dispute_stage, + dispute_status: common_enums::DisputeStatus::foreign_try_from(event_type) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("event type to dispute status mapping failed")?, + payment_id: payment_attempt.payment_id.to_owned(), + connector: connector_name.to_owned(), + attempt_id: payment_attempt.attempt_id.to_owned(), + merchant_id: merchant_id.to_owned(), + connector_status: dispute_details.connector_status, + connector_dispute_id: dispute_details.connector_dispute_id, + connector_reason: dispute_details.connector_reason, + connector_reason_code: dispute_details.connector_reason_code, + challenge_required_by: dispute_details.challenge_required_by, + connector_created_at: dispute_details.created_at, + connector_updated_at: dispute_details.updated_at, + profile_id: Some(business_profile.profile_id.clone()), + evidence: None, + merchant_connector_id: payment_attempt.merchant_connector_id.clone(), + dispute_amount: dispute_details.amount.parse::().unwrap_or(0), + }; + state + .store + .insert_dispute(new_dispute.clone()) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) + } + Some(dispute) => { + logger::info!("Dispute Already exists, Updating the dispute details"); + metrics::INCOMING_DISPUTE_WEBHOOK_UPDATE_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]); + let dispute_status = diesel_models::enums::DisputeStatus::foreign_try_from(event_type) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("event type to dispute state conversion failure")?; + crate::core::utils::validate_dispute_stage_and_dispute_status( + dispute.dispute_stage, + dispute.dispute_status, + dispute_details.dispute_stage, + dispute_status, + ) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("dispute stage and status validation failed")?; + let update_dispute = diesel_models::dispute::DisputeUpdate::Update { + dispute_stage: dispute_details.dispute_stage, + dispute_status, + connector_status: dispute_details.connector_status, + connector_reason: dispute_details.connector_reason, + connector_reason_code: dispute_details.connector_reason_code, + challenge_required_by: dispute_details.challenge_required_by, + connector_updated_at: dispute_details.updated_at, + }; + db.update_dispute(dispute, update_dispute) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) + } + } +} + +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +async fn external_authentication_incoming_webhook_flow( + state: SessionState, + req_state: ReqState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + source_verified: bool, + event_type: webhooks::IncomingWebhookEvent, + request_details: &api::IncomingWebhookRequestDetails<'_>, + connector: &(dyn api::Connector + Sync), + object_ref_id: api::ObjectReferenceId, + business_profile: diesel_models::business_profile::BusinessProfile, + merchant_connector_account: domain::MerchantConnectorAccount, +) -> CustomResult { + if source_verified { + let authentication_details = connector + .get_external_authentication_details(request_details) + .switch()?; + let trans_status = authentication_details.trans_status; + let authentication_update = storage::AuthenticationUpdate::PostAuthenticationUpdate { + authentication_status: common_enums::AuthenticationStatus::foreign_from( + trans_status.clone(), + ), + trans_status, + authentication_value: authentication_details.authentication_value, + eci: authentication_details.eci, + }; + let authentication = + if let webhooks::ObjectReferenceId::ExternalAuthenticationID(authentication_id_type) = + object_ref_id + { + match authentication_id_type { + webhooks::AuthenticationIdType::AuthenticationId(authentication_id) => state + .store + .find_authentication_by_merchant_id_authentication_id( + merchant_account.merchant_id.clone(), + authentication_id.clone(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::AuthenticationNotFound { + id: authentication_id, + }) + .attach_printable("Error while fetching authentication record"), + webhooks::AuthenticationIdType::ConnectorAuthenticationId( + connector_authentication_id, + ) => state + .store + .find_authentication_by_merchant_id_connector_authentication_id( + merchant_account.merchant_id.clone(), + connector_authentication_id.clone(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::AuthenticationNotFound { + id: connector_authentication_id, + }) + .attach_printable("Error while fetching authentication record"), + } + } else { + Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( + "received a non-external-authentication id for retrieving authentication", + ) + }?; + let updated_authentication = state + .store + .update_authentication_by_merchant_id_authentication_id( + authentication, + authentication_update, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error while updating authentication")?; + // Check if it's a payment authentication flow, payment_id would be there only for payment authentication flows + if let Some(payment_id) = updated_authentication.payment_id { + let is_pull_mechanism_enabled = helper_utils::check_if_pull_mechanism_for_external_3ds_enabled_from_connector_metadata(merchant_connector_account.metadata.map(|metadata| metadata.expose())); + // Merchant doesn't have pull mechanism enabled and if it's challenge flow, we have to authorize whenever we receive a ARes webhook + if !is_pull_mechanism_enabled + && updated_authentication.authentication_type + == Some(common_enums::DecoupledAuthenticationType::Challenge) + && event_type == webhooks::IncomingWebhookEvent::ExternalAuthenticationARes + { + let payment_confirm_req = api::PaymentsRequest { + payment_id: Some(api_models::payments::PaymentIdType::PaymentIntentId( + payment_id, + )), + merchant_id: Some(merchant_account.merchant_id.clone()), + ..Default::default() + }; + let payments_response = Box::pin(payments::payments_core::< + api::Authorize, + api::PaymentsResponse, + _, + _, + _, + >( + state.clone(), + req_state, + merchant_account.clone(), + key_store.clone(), + payments::PaymentConfirm, + payment_confirm_req, + services::api::AuthFlow::Merchant, + payments::CallConnectorAction::Trigger, + None, + HeaderPayload::with_source(enums::PaymentSource::ExternalAuthenticator), + )) + .await?; + match payments_response { + services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { + let payment_id = payments_response + .payment_id + .clone() + .get_required_value("payment_id") + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("payment id not received from payments core")?; + let status = payments_response.status; + let event_type: Option = + payments_response.status.foreign_into(); + // Set poll_id as completed in redis to allow the fetch status of poll through retrieve_poll_status api from client + let poll_id = core_utils::get_poll_id( + merchant_account.merchant_id.clone(), + core_utils::get_external_authentication_request_poll_id(&payment_id), + ); + let redis_conn = state + .store + .get_redis_conn() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to get redis connection")?; + redis_conn + .set_key_without_modifying_ttl( + &poll_id, + api_models::poll::PollStatus::Completed.to_string(), + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to add poll_id in redis")?; + // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook + if let Some(outgoing_event_type) = event_type { + let primary_object_created_at = payments_response.created; + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Payments, + payment_id.clone(), + enums::EventObjectType::PaymentDetails, + api::OutgoingWebhookContent::PaymentDetails(payments_response), + primary_object_created_at, + ) + .await?; + }; + let response = WebhookResponseTracker::Payment { payment_id, status }; + Ok(response) + } + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( + "Did not get payment id as object reference id in webhook payments flow", + )?, + } + } else { + Ok(WebhookResponseTracker::NoEffect) + } + } else { + Ok(WebhookResponseTracker::NoEffect) + } + } else { + logger::error!( + "Webhook source verification failed for external authentication webhook flow" + ); + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + } +} + +#[instrument(skip_all)] +async fn mandates_incoming_webhook_flow( + state: SessionState, + merchant_account: domain::MerchantAccount, + business_profile: diesel_models::business_profile::BusinessProfile, + key_store: domain::MerchantKeyStore, + webhook_details: api::IncomingWebhookDetails, + source_verified: bool, + event_type: webhooks::IncomingWebhookEvent, +) -> CustomResult { + if source_verified { + let db = &*state.store; + let mandate = match webhook_details.object_reference_id { + webhooks::ObjectReferenceId::MandateId(webhooks::MandateIdType::MandateId( + mandate_id, + )) => db + .find_mandate_by_merchant_id_mandate_id( + &merchant_account.merchant_id, + mandate_id.as_str(), + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?, + webhooks::ObjectReferenceId::MandateId( + webhooks::MandateIdType::ConnectorMandateId(connector_mandate_id), + ) => db + .find_mandate_by_merchant_id_connector_mandate_id( + &merchant_account.merchant_id, + connector_mandate_id.as_str(), + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?, + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("received a non-mandate id for retrieving mandate")?, + }; + let mandate_status = common_enums::MandateStatus::foreign_try_from(event_type) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("event type to mandate status mapping failed")?; + let mandate_id = mandate.mandate_id.clone(); + let updated_mandate = db + .update_mandate_by_merchant_id_mandate_id( + &merchant_account.merchant_id, + &mandate_id, + storage::MandateUpdate::StatusUpdate { mandate_status }, + mandate, + merchant_account.storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?; + let mandates_response = Box::new( + api::mandates::MandateResponse::from_db_mandate( + &state, + key_store.clone(), + updated_mandate.clone(), + merchant_account.storage_scheme, + ) + .await?, + ); + let event_type: Option = updated_mandate.mandate_status.foreign_into(); + if let Some(outgoing_event_type) = event_type { + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Mandates, + updated_mandate.mandate_id.clone(), + enums::EventObjectType::MandateDetails, + api::OutgoingWebhookContent::MandateDetails(mandates_response), + Some(updated_mandate.created_at), + ) + .await?; + } + Ok(WebhookResponseTracker::Mandate { + mandate_id: updated_mandate.mandate_id, + status: updated_mandate.mandate_status, + }) + } else { + logger::error!("Webhook source verification failed for mandates webhook flow"); + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + } +} + +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +async fn frm_incoming_webhook_flow( + state: SessionState, + req_state: ReqState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + source_verified: bool, + event_type: webhooks::IncomingWebhookEvent, + object_ref_id: api::ObjectReferenceId, + business_profile: diesel_models::business_profile::BusinessProfile, +) -> CustomResult { + if source_verified { + let payment_attempt = + get_payment_attempt_from_object_reference_id(&state, object_ref_id, &merchant_account) + .await?; + let payment_response = match event_type { + webhooks::IncomingWebhookEvent::FrmApproved => { + Box::pin(payments::payments_core::< + api::Capture, + api::PaymentsResponse, + _, + _, + _, + >( + state.clone(), + req_state, + merchant_account.clone(), + key_store.clone(), + payments::PaymentApprove, + api::PaymentsCaptureRequest { + payment_id: payment_attempt.payment_id, + amount_to_capture: payment_attempt.amount_to_capture, + ..Default::default() + }, + services::api::AuthFlow::Merchant, + payments::CallConnectorAction::Trigger, + None, + HeaderPayload::default(), + )) + .await? + } + webhooks::IncomingWebhookEvent::FrmRejected => { + Box::pin(payments::payments_core::< + api::Void, + api::PaymentsResponse, + _, + _, + _, + >( + state.clone(), + req_state, + merchant_account.clone(), + key_store.clone(), + payments::PaymentReject, + api::PaymentsCancelRequest { + payment_id: payment_attempt.payment_id.clone(), + cancellation_reason: Some( + "Rejected by merchant based on FRM decision".to_string(), + ), + ..Default::default() + }, + services::api::AuthFlow::Merchant, + payments::CallConnectorAction::Trigger, + None, + HeaderPayload::default(), + )) + .await? + } + _ => Err(errors::ApiErrorResponse::EventNotFound)?, + }; + match payment_response { + services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { + let payment_id = payments_response + .payment_id + .clone() + .get_required_value("payment_id") + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("payment id not received from payments core")?; + let status = payments_response.status; + let event_type: Option = payments_response.status.foreign_into(); + if let Some(outgoing_event_type) = event_type { + let primary_object_created_at = payments_response.created; + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Payments, + payment_id.clone(), + enums::EventObjectType::PaymentDetails, + api::OutgoingWebhookContent::PaymentDetails(payments_response), + primary_object_created_at, + ) + .await?; + }; + let response = WebhookResponseTracker::Payment { payment_id, status }; + Ok(response) + } + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable( + "Did not get payment id as object reference id in webhook payments flow", + )?, + } + } else { + logger::error!("Webhook source verification failed for frm webhooks flow"); + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + } +} + +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +async fn disputes_incoming_webhook_flow( + state: SessionState, + merchant_account: domain::MerchantAccount, + business_profile: diesel_models::business_profile::BusinessProfile, + key_store: domain::MerchantKeyStore, + webhook_details: api::IncomingWebhookDetails, + source_verified: bool, + connector: &(dyn api::Connector + Sync), + request_details: &api::IncomingWebhookRequestDetails<'_>, + event_type: webhooks::IncomingWebhookEvent, +) -> CustomResult { + metrics::INCOMING_DISPUTE_WEBHOOK_METRIC.add(&metrics::CONTEXT, 1, &[]); + if source_verified { + let db = &*state.store; + let dispute_details = connector.get_dispute_details(request_details).switch()?; + let payment_attempt = get_payment_attempt_from_object_reference_id( + &state, + webhook_details.object_reference_id, + &merchant_account, + ) + .await?; + let option_dispute = db + .find_by_merchant_id_payment_id_connector_dispute_id( + &merchant_account.merchant_id, + &payment_attempt.payment_id, + &dispute_details.connector_dispute_id, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound)?; + let dispute_object = get_or_update_dispute_object( + state.clone(), + option_dispute, + dispute_details, + &merchant_account.merchant_id, + &payment_attempt, + event_type, + &business_profile, + connector.id(), + ) + .await?; + let disputes_response = Box::new(dispute_object.clone().foreign_into()); + let event_type: enums::EventType = dispute_object.dispute_status.foreign_into(); + + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + event_type, + enums::EventClass::Disputes, + dispute_object.dispute_id.clone(), + enums::EventObjectType::DisputeDetails, + api::OutgoingWebhookContent::DisputeDetails(disputes_response), + Some(dispute_object.created_at), + ) + .await?; + metrics::INCOMING_DISPUTE_WEBHOOK_MERCHANT_NOTIFIED_METRIC.add(&metrics::CONTEXT, 1, &[]); + Ok(WebhookResponseTracker::Dispute { + dispute_id: dispute_object.dispute_id, + payment_id: dispute_object.payment_id, + status: dispute_object.dispute_status, + }) + } else { + metrics::INCOMING_DISPUTE_WEBHOOK_SIGNATURE_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]); + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + } +} + +#[instrument(skip_all)] +async fn bank_transfer_webhook_flow( + state: SessionState, + req_state: ReqState, + merchant_account: domain::MerchantAccount, + business_profile: diesel_models::business_profile::BusinessProfile, + key_store: domain::MerchantKeyStore, + webhook_details: api::IncomingWebhookDetails, + source_verified: bool, +) -> CustomResult { + let response = if source_verified { + let payment_attempt = get_payment_attempt_from_object_reference_id( + &state, + webhook_details.object_reference_id, + &merchant_account, + ) + .await?; + let payment_id = payment_attempt.payment_id; + let request = api::PaymentsRequest { + payment_id: Some(api_models::payments::PaymentIdType::PaymentIntentId( + payment_id, + )), + payment_token: payment_attempt.payment_token, + ..Default::default() + }; + Box::pin(payments::payments_core::< + api::Authorize, + api::PaymentsResponse, + _, + _, + _, + >( + state.clone(), + req_state, + merchant_account.to_owned(), + key_store.clone(), + payments::PaymentConfirm, + request, + services::api::AuthFlow::Merchant, + payments::CallConnectorAction::Trigger, + None, + HeaderPayload::with_source(common_enums::PaymentSource::Webhook), + )) + .await + } else { + Err(report!( + errors::ApiErrorResponse::WebhookAuthenticationFailed + )) + }; + + match response? { + services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { + let payment_id = payments_response + .payment_id + .clone() + .get_required_value("payment_id") + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("did not receive payment id from payments core response")?; + + let event_type: Option = payments_response.status.foreign_into(); + let status = payments_response.status; + + // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook + if let Some(outgoing_event_type) = event_type { + let primary_object_created_at = payments_response.created; + super::create_event_and_trigger_outgoing_webhook( + state, + merchant_account, + business_profile, + &key_store, + outgoing_event_type, + enums::EventClass::Payments, + payment_id.clone(), + enums::EventObjectType::PaymentDetails, + api::OutgoingWebhookContent::PaymentDetails(payments_response), + primary_object_created_at, + ) + .await?; + } + + Ok(WebhookResponseTracker::Payment { payment_id, status }) + } + + _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("received non-json response from payments core")?, + } +} + +#[inline] +async fn get_payment_id( + db: &dyn StorageInterface, + payment_id: &api::PaymentIdType, + merchant_id: &str, + storage_scheme: enums::MerchantStorageScheme, +) -> errors::RouterResult { + let pay_id = || async { + match payment_id { + api_models::payments::PaymentIdType::PaymentIntentId(ref id) => Ok(id.to_string()), + api_models::payments::PaymentIdType::ConnectorTransactionId(ref id) => db + .find_payment_attempt_by_merchant_id_connector_txn_id( + merchant_id, + id, + storage_scheme, + ) + .await + .map(|p| p.payment_id), + api_models::payments::PaymentIdType::PaymentAttemptId(ref id) => db + .find_payment_attempt_by_attempt_id_merchant_id(id, merchant_id, storage_scheme) + .await + .map(|p| p.payment_id), + api_models::payments::PaymentIdType::PreprocessingId(ref id) => db + .find_payment_attempt_by_preprocessing_id_merchant_id( + id, + merchant_id, + storage_scheme, + ) + .await + .map(|p| p.payment_id), + } + }; + + pay_id() + .await + .to_not_found_response(errors::ApiErrorResponse::PaymentNotFound) +} + +fn get_connector_by_connector_name( + state: &SessionState, + connector_name: &str, + merchant_connector_id: Option, +) -> CustomResult<(&'static (dyn api::Connector + Sync), String), errors::ApiErrorResponse> { + let authentication_connector = + api_models::enums::convert_authentication_connector(connector_name); + #[cfg(feature = "frm")] + { + let frm_connector = api_models::enums::convert_frm_connector(connector_name); + if frm_connector.is_some() { + let frm_connector_data = + api::FraudCheckConnectorData::get_connector_by_name(connector_name)?; + return Ok(( + *frm_connector_data.connector, + frm_connector_data.connector_name.to_string(), + )); + } + } + + let (connector, connector_name) = if authentication_connector.is_some() { + let authentication_connector_data = + api::AuthenticationConnectorData::get_connector_by_name(connector_name)?; + ( + authentication_connector_data.connector, + authentication_connector_data.connector_name.to_string(), + ) + } else { + let connector_data = api::ConnectorData::get_connector_by_name( + &state.conf.connectors, + connector_name, + api::GetToken::Connector, + merchant_connector_id, + ) + .change_context(errors::ApiErrorResponse::InvalidRequestData { + message: "invalid connector name received".to_string(), + }) + .attach_printable("Failed construction of ConnectorData")?; + ( + connector_data.connector, + connector_data.connector_name.to_string(), + ) + }; + Ok((*connector, connector_name)) +} + +/// This function fetches the merchant connector account ( if the url used is /{merchant_connector_id}) +/// if merchant connector id is not passed in the request, then this will return None for mca +async fn fetch_optional_mca_and_connector( + state: &SessionState, + merchant_account: &domain::MerchantAccount, + connector_name_or_mca_id: &str, + key_store: &domain::MerchantKeyStore, +) -> CustomResult< + ( + Option, + &'static (dyn api::Connector + Sync), + String, + ), + errors::ApiErrorResponse, +> { + let db = &state.store; + if connector_name_or_mca_id.starts_with("mca_") { + let mca = db + .find_by_merchant_connector_account_merchant_id_merchant_connector_id( + &merchant_account.merchant_id, + connector_name_or_mca_id, + key_store, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { + id: connector_name_or_mca_id.to_string(), + }) + .attach_printable( + "error while fetching merchant_connector_account from connector_id", + )?; + let (connector, connector_name) = get_connector_by_connector_name( + state, + &mca.connector_name, + Some(mca.merchant_connector_id.clone()), + )?; + + Ok((Some(mca), connector, connector_name)) + } else { + // Merchant connector account is already being queried, it is safe to set connector id as None + let (connector, connector_name) = + get_connector_by_connector_name(state, connector_name_or_mca_id, None)?; + Ok((None, connector, connector_name)) + } +} diff --git a/crates/router/src/core/webhooks/outgoing.rs b/crates/router/src/core/webhooks/outgoing.rs new file mode 100644 index 000000000000..94f96c452166 --- /dev/null +++ b/crates/router/src/core/webhooks/outgoing.rs @@ -0,0 +1,775 @@ +use api_models::{ + webhook_events::{OutgoingWebhookRequestContent, OutgoingWebhookResponseContent}, + webhooks, +}; +use common_utils::{ext_traits::Encode, request::RequestContent}; +use error_stack::{report, ResultExt}; +use masking::{ExposeInterface, Mask, PeekInterface, Secret}; +use router_env::{ + instrument, + tracing::{self, Instrument}, +}; + +use super::{types, utils, MERCHANT_ID}; +#[cfg(feature = "stripe")] +use crate::compatibility::stripe::webhooks as stripe_webhooks; +use crate::{ + core::{ + errors::{self, CustomResult}, + metrics, + }, + db::StorageInterface, + events::outgoing_webhook_logs::{OutgoingWebhookEvent, OutgoingWebhookEventMetric}, + logger, + routes::{app::SessionStateInfo, metrics::request::add_attributes, SessionState}, + services, + types::{ + api, + domain::{self, types as domain_types}, + storage::{self, enums}, + }, + utils::{OptionExt, ValueExt}, + workflows::outgoing_webhook_retry, +}; + +const OUTGOING_WEBHOOK_TIMEOUT_SECS: u64 = 5; + +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +pub(crate) async fn create_event_and_trigger_outgoing_webhook( + state: SessionState, + merchant_account: domain::MerchantAccount, + business_profile: diesel_models::business_profile::BusinessProfile, + merchant_key_store: &domain::MerchantKeyStore, + event_type: enums::EventType, + event_class: enums::EventClass, + primary_object_id: String, + primary_object_type: enums::EventObjectType, + content: api::OutgoingWebhookContent, + primary_object_created_at: Option, +) -> CustomResult<(), errors::ApiErrorResponse> { + let delivery_attempt = enums::WebhookDeliveryAttempt::InitialAttempt; + let idempotent_event_id = + utils::get_idempotent_event_id(&primary_object_id, event_type, delivery_attempt); + let webhook_url_result = get_webhook_url_from_business_profile(&business_profile); + + if !state.conf.webhooks.outgoing_enabled + || webhook_url_result.is_err() + || webhook_url_result.as_ref().is_ok_and(String::is_empty) + { + logger::debug!( + business_profile_id=%business_profile.profile_id, + %idempotent_event_id, + "Outgoing webhooks are disabled in application configuration, or merchant webhook URL \ + could not be obtained; skipping outgoing webhooks for event" + ); + return Ok(()); + } + + let event_id = utils::generate_event_id(); + let merchant_id = business_profile.merchant_id.clone(); + let now = common_utils::date_time::now(); + + let outgoing_webhook = api::OutgoingWebhook { + merchant_id: merchant_id.clone(), + event_id: event_id.clone(), + event_type, + content: content.clone(), + timestamp: now, + }; + + let request_content = get_outgoing_webhook_request( + &merchant_account, + outgoing_webhook, + business_profile.payment_response_hash_key.as_deref(), + ) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("Failed to construct outgoing webhook request content")?; + + let new_event = domain::Event { + event_id: event_id.clone(), + event_type, + event_class, + is_webhook_notified: false, + primary_object_id, + primary_object_type, + created_at: now, + merchant_id: Some(business_profile.merchant_id.clone()), + business_profile_id: Some(business_profile.profile_id.clone()), + primary_object_created_at, + idempotent_event_id: Some(idempotent_event_id.clone()), + initial_attempt_id: Some(event_id.clone()), + request: Some( + domain_types::encrypt( + request_content + .encode_to_string_of_json() + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("Failed to encode outgoing webhook request content") + .map(Secret::new)?, + merchant_key_store.key.get_inner().peek(), + ) + .await + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("Failed to encrypt outgoing webhook request content")?, + ), + response: None, + delivery_attempt: Some(delivery_attempt), + }; + + let event_insert_result = state + .store + .insert_event(new_event, merchant_key_store) + .await; + + let event = match event_insert_result { + Ok(event) => Ok(event), + Err(error) => { + if error.current_context().is_db_unique_violation() { + logger::debug!("Event with idempotent ID `{idempotent_event_id}` already exists in the database"); + return Ok(()); + } else { + logger::error!(event_insertion_failure=?error); + Err(error + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("Failed to insert event in events table")) + } + } + }?; + + let process_tracker = add_outgoing_webhook_retry_task_to_process_tracker( + &*state.store, + &business_profile, + &event, + ) + .await + .map_err(|error| { + logger::error!( + ?error, + "Failed to add outgoing webhook retry task to process tracker" + ); + error + }) + .ok(); + + let cloned_key_store = merchant_key_store.clone(); + // Using a tokio spawn here and not arbiter because not all caller of this function + // may have an actix arbiter + tokio::spawn( + async move { + Box::pin(trigger_webhook_and_raise_event( + state, + business_profile, + &cloned_key_store, + event, + request_content, + delivery_attempt, + Some(content), + process_tracker, + )) + .await; + } + .in_current_span(), + ); + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +pub(crate) async fn trigger_webhook_and_raise_event( + state: SessionState, + business_profile: diesel_models::business_profile::BusinessProfile, + merchant_key_store: &domain::MerchantKeyStore, + event: domain::Event, + request_content: OutgoingWebhookRequestContent, + delivery_attempt: enums::WebhookDeliveryAttempt, + content: Option, + process_tracker: Option, +) { + logger::debug!( + event_id=%event.event_id, + idempotent_event_id=?event.idempotent_event_id, + initial_attempt_id=?event.initial_attempt_id, + "Attempting to send webhook" + ); + + let merchant_id = business_profile.merchant_id.clone(); + let trigger_webhook_result = trigger_webhook_to_merchant( + state.clone(), + business_profile, + merchant_key_store, + event.clone(), + request_content, + delivery_attempt, + process_tracker, + ) + .await; + + raise_webhooks_analytics_event(state, trigger_webhook_result, content, merchant_id, event); +} + +async fn trigger_webhook_to_merchant( + state: SessionState, + business_profile: diesel_models::business_profile::BusinessProfile, + merchant_key_store: &domain::MerchantKeyStore, + event: domain::Event, + request_content: OutgoingWebhookRequestContent, + delivery_attempt: enums::WebhookDeliveryAttempt, + process_tracker: Option, +) -> CustomResult<(), errors::WebhooksFlowError> { + let webhook_url = match ( + get_webhook_url_from_business_profile(&business_profile), + process_tracker.clone(), + ) { + (Ok(webhook_url), _) => Ok(webhook_url), + (Err(error), Some(process_tracker)) => { + if !error + .current_context() + .is_webhook_delivery_retryable_error() + { + logger::debug!("Failed to obtain merchant webhook URL, aborting retries"); + state + .store + .as_scheduler() + .finish_process_with_business_status(process_tracker, "FAILURE".into()) + .await + .change_context( + errors::WebhooksFlowError::OutgoingWebhookProcessTrackerTaskUpdateFailed, + )?; + } + Err(error) + } + (Err(error), None) => Err(error), + }?; + + let event_id = event.event_id; + + let headers = request_content + .headers + .into_iter() + .map(|(name, value)| (name, value.into_masked())) + .collect(); + let request = services::RequestBuilder::new() + .method(services::Method::Post) + .url(&webhook_url) + .attach_default_headers() + .headers(headers) + .set_body(RequestContent::RawBytes( + request_content.body.expose().into_bytes(), + )) + .build(); + + let response = state + .api_client + .send_request(&state, request, Some(OUTGOING_WEBHOOK_TIMEOUT_SECS), false) + .await; + + metrics::WEBHOOK_OUTGOING_COUNT.add( + &metrics::CONTEXT, + 1, + &[metrics::KeyValue::new( + MERCHANT_ID, + business_profile.merchant_id.clone(), + )], + ); + logger::debug!(outgoing_webhook_response=?response); + + let update_event_if_client_error = + |state: SessionState, + merchant_key_store: domain::MerchantKeyStore, + merchant_id: String, + event_id: String, + error_message: String| async move { + let is_webhook_notified = false; + + let response_to_store = OutgoingWebhookResponseContent { + body: None, + headers: None, + status_code: None, + error_message: Some(error_message), + }; + + let event_update = domain::EventUpdate::UpdateResponse { + is_webhook_notified, + response: Some( + domain_types::encrypt( + response_to_store + .encode_to_string_of_json() + .change_context( + errors::WebhooksFlowError::OutgoingWebhookResponseEncodingFailed, + ) + .map(Secret::new)?, + merchant_key_store.key.get_inner().peek(), + ) + .await + .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) + .attach_printable("Failed to encrypt outgoing webhook response content")?, + ), + }; + + state + .store + .update_event_by_merchant_id_event_id( + &merchant_id, + &event_id, + event_update, + &merchant_key_store, + ) + .await + .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) + }; + + let api_client_error_handler = + |state: SessionState, + merchant_key_store: domain::MerchantKeyStore, + merchant_id: String, + event_id: String, + client_error: error_stack::Report, + delivery_attempt: enums::WebhookDeliveryAttempt| async move { + // Not including detailed error message in response information since it contains too + // much of diagnostic information to be exposed to the merchant. + update_event_if_client_error( + state, + merchant_key_store, + merchant_id, + event_id, + "Unable to send request to merchant server".to_string(), + ) + .await?; + + let error = + client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed); + logger::error!( + ?error, + ?delivery_attempt, + "An error occurred when sending webhook to merchant" + ); + + Ok::<_, error_stack::Report>(()) + }; + let update_event_in_storage = |state: SessionState, + merchant_key_store: domain::MerchantKeyStore, + merchant_id: String, + event_id: String, + response: reqwest::Response| async move { + let status_code = response.status(); + let is_webhook_notified = status_code.is_success(); + + let response_headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value + .to_str() + .map(|s| Secret::from(String::from(s))) + .unwrap_or_else(|error| { + logger::warn!( + "Response header {} contains non-UTF-8 characters: {error:?}", + name.as_str() + ); + Secret::from(String::from("Non-UTF-8 header value")) + }), + ) + }) + .collect::>(); + let response_body = response + .text() + .await + .map(Secret::from) + .unwrap_or_else(|error| { + logger::warn!("Response contains non-UTF-8 characters: {error:?}"); + Secret::from(String::from("Non-UTF-8 response body")) + }); + let response_to_store = OutgoingWebhookResponseContent { + body: Some(response_body), + headers: Some(response_headers), + status_code: Some(status_code.as_u16()), + error_message: None, + }; + + let event_update = domain::EventUpdate::UpdateResponse { + is_webhook_notified, + response: Some( + domain_types::encrypt( + response_to_store + .encode_to_string_of_json() + .change_context( + errors::WebhooksFlowError::OutgoingWebhookResponseEncodingFailed, + ) + .map(Secret::new)?, + merchant_key_store.key.get_inner().peek(), + ) + .await + .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) + .attach_printable("Failed to encrypt outgoing webhook response content")?, + ), + }; + state + .store + .update_event_by_merchant_id_event_id( + &merchant_id, + &event_id, + event_update, + &merchant_key_store, + ) + .await + .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) + }; + let increment_webhook_outgoing_received_count = |merchant_id: String| { + metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( + &metrics::CONTEXT, + 1, + &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], + ) + }; + let success_response_handler = + |state: SessionState, + merchant_id: String, + process_tracker: Option, + business_status: &'static str| async move { + increment_webhook_outgoing_received_count(merchant_id); + + match process_tracker { + Some(process_tracker) => state + .store + .as_scheduler() + .finish_process_with_business_status(process_tracker, business_status.into()) + .await + .change_context( + errors::WebhooksFlowError::OutgoingWebhookProcessTrackerTaskUpdateFailed, + ), + None => Ok(()), + } + }; + let error_response_handler = |merchant_id: String, + delivery_attempt: enums::WebhookDeliveryAttempt, + status_code: u16, + log_message: &'static str| { + metrics::WEBHOOK_OUTGOING_NOT_RECEIVED_COUNT.add( + &metrics::CONTEXT, + 1, + &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], + ); + + let error = report!(errors::WebhooksFlowError::NotReceivedByMerchant); + logger::warn!(?error, ?delivery_attempt, ?status_code, %log_message); + }; + + match delivery_attempt { + enums::WebhookDeliveryAttempt::InitialAttempt => match response { + Err(client_error) => { + api_client_error_handler( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + client_error, + delivery_attempt, + ) + .await? + } + Ok(response) => { + let status_code = response.status(); + let _updated_event = update_event_in_storage( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + response, + ) + .await?; + + if status_code.is_success() { + success_response_handler( + state.clone(), + business_profile.merchant_id, + process_tracker, + "INITIAL_DELIVERY_ATTEMPT_SUCCESSFUL", + ) + .await?; + } else { + error_response_handler( + business_profile.merchant_id, + delivery_attempt, + status_code.as_u16(), + "Ignoring error when sending webhook to merchant", + ); + } + } + }, + enums::WebhookDeliveryAttempt::AutomaticRetry => { + let process_tracker = process_tracker + .get_required_value("process_tracker") + .change_context(errors::WebhooksFlowError::OutgoingWebhookRetrySchedulingFailed) + .attach_printable("`process_tracker` is unavailable in automatic retry flow")?; + match response { + Err(client_error) => { + api_client_error_handler( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + client_error, + delivery_attempt, + ) + .await?; + // Schedule a retry attempt for webhook delivery + outgoing_webhook_retry::retry_webhook_delivery_task( + &*state.store, + &business_profile.merchant_id, + process_tracker, + ) + .await + .change_context( + errors::WebhooksFlowError::OutgoingWebhookRetrySchedulingFailed, + )?; + } + Ok(response) => { + let status_code = response.status(); + let _updated_event = update_event_in_storage( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + response, + ) + .await?; + + if status_code.is_success() { + success_response_handler( + state.clone(), + business_profile.merchant_id, + Some(process_tracker), + "COMPLETED_BY_PT", + ) + .await?; + } else { + error_response_handler( + business_profile.merchant_id.clone(), + delivery_attempt, + status_code.as_u16(), + "An error occurred when sending webhook to merchant", + ); + // Schedule a retry attempt for webhook delivery + outgoing_webhook_retry::retry_webhook_delivery_task( + &*state.store, + &business_profile.merchant_id, + process_tracker, + ) + .await + .change_context( + errors::WebhooksFlowError::OutgoingWebhookRetrySchedulingFailed, + )?; + } + } + } + } + enums::WebhookDeliveryAttempt::ManualRetry => match response { + Err(client_error) => { + api_client_error_handler( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + client_error, + delivery_attempt, + ) + .await? + } + Ok(response) => { + let status_code = response.status(); + let _updated_event = update_event_in_storage( + state.clone(), + merchant_key_store.clone(), + business_profile.merchant_id.clone(), + event_id.clone(), + response, + ) + .await?; + + if status_code.is_success() { + increment_webhook_outgoing_received_count(business_profile.merchant_id.clone()); + } else { + error_response_handler( + business_profile.merchant_id, + delivery_attempt, + status_code.as_u16(), + "Ignoring error when sending webhook to merchant", + ); + } + } + }, + } + + Ok(()) +} + +fn raise_webhooks_analytics_event( + state: SessionState, + trigger_webhook_result: CustomResult<(), errors::WebhooksFlowError>, + content: Option, + merchant_id: String, + event: domain::Event, +) { + let error = if let Err(error) = trigger_webhook_result { + logger::error!(?error, "Failed to send webhook to merchant"); + + serde_json::to_value(error.current_context()) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .map_err(|error| { + logger::error!(?error, "Failed to serialize outgoing webhook error as JSON"); + error + }) + .ok() + } else { + None + }; + + let outgoing_webhook_event_content = content + .as_ref() + .and_then(api::OutgoingWebhookContent::get_outgoing_webhook_event_content); + let webhook_event = OutgoingWebhookEvent::new( + merchant_id, + event.event_id, + event.event_type, + outgoing_webhook_event_content, + error, + event.initial_attempt_id, + ); + state.event_handler().log_event(&webhook_event); +} + +pub(crate) async fn add_outgoing_webhook_retry_task_to_process_tracker( + db: &dyn StorageInterface, + business_profile: &diesel_models::business_profile::BusinessProfile, + event: &domain::Event, +) -> CustomResult { + let schedule_time = outgoing_webhook_retry::get_webhook_delivery_retry_schedule_time( + db, + &business_profile.merchant_id, + 0, + ) + .await + .ok_or(errors::StorageError::ValueNotFound( + "Process tracker schedule time".into(), // Can raise a better error here + )) + .attach_printable("Failed to obtain initial process tracker schedule time")?; + + let tracking_data = types::OutgoingWebhookTrackingData { + merchant_id: business_profile.merchant_id.clone(), + business_profile_id: business_profile.profile_id.clone(), + event_type: event.event_type, + event_class: event.event_class, + primary_object_id: event.primary_object_id.clone(), + primary_object_type: event.primary_object_type, + initial_attempt_id: event.initial_attempt_id.clone(), + }; + + let runner = storage::ProcessTrackerRunner::OutgoingWebhookRetryWorkflow; + let task = "OUTGOING_WEBHOOK_RETRY"; + let tag = ["OUTGOING_WEBHOOKS"]; + let process_tracker_id = scheduler::utils::get_process_tracker_id( + runner, + task, + &event.event_id, + &business_profile.merchant_id, + ); + let process_tracker_entry = storage::ProcessTrackerNew::new( + process_tracker_id, + task, + runner, + tag, + tracking_data, + schedule_time, + ) + .map_err(errors::StorageError::from)?; + + match db.insert_process(process_tracker_entry).await { + Ok(process_tracker) => { + crate::routes::metrics::TASKS_ADDED_COUNT.add( + &metrics::CONTEXT, + 1, + &[add_attributes("flow", "OutgoingWebhookRetry")], + ); + Ok(process_tracker) + } + Err(error) => { + crate::routes::metrics::TASK_ADDITION_FAILURES_COUNT.add( + &metrics::CONTEXT, + 1, + &[add_attributes("flow", "OutgoingWebhookRetry")], + ); + Err(error) + } + } +} + +fn get_webhook_url_from_business_profile( + business_profile: &diesel_models::business_profile::BusinessProfile, +) -> CustomResult { + let webhook_details_json = business_profile + .webhook_details + .clone() + .get_required_value("webhook_details") + .change_context(errors::WebhooksFlowError::MerchantWebhookDetailsNotFound)?; + + let webhook_details: api::WebhookDetails = + webhook_details_json + .parse_value("WebhookDetails") + .change_context(errors::WebhooksFlowError::MerchantWebhookDetailsNotFound)?; + + webhook_details + .webhook_url + .get_required_value("webhook_url") + .change_context(errors::WebhooksFlowError::MerchantWebhookUrlNotConfigured) + .map(ExposeInterface::expose) +} + +pub(crate) fn get_outgoing_webhook_request( + merchant_account: &domain::MerchantAccount, + outgoing_webhook: api::OutgoingWebhook, + payment_response_hash_key: Option<&str>, +) -> CustomResult { + #[inline] + fn get_outgoing_webhook_request_inner( + outgoing_webhook: api::OutgoingWebhook, + payment_response_hash_key: Option<&str>, + ) -> CustomResult { + let mut headers = vec![( + reqwest::header::CONTENT_TYPE.to_string(), + mime::APPLICATION_JSON.essence_str().into(), + )]; + + let transformed_outgoing_webhook = WebhookType::from(outgoing_webhook); + + let outgoing_webhooks_signature = transformed_outgoing_webhook + .get_outgoing_webhooks_signature(payment_response_hash_key)?; + + if let Some(signature) = outgoing_webhooks_signature.signature { + WebhookType::add_webhook_header(&mut headers, signature) + } + + Ok(OutgoingWebhookRequestContent { + body: outgoing_webhooks_signature.payload, + headers: headers + .into_iter() + .map(|(name, value)| (name, Secret::new(value.into_inner()))) + .collect(), + }) + } + + match merchant_account.get_compatible_connector() { + #[cfg(feature = "stripe")] + Some(api_models::enums::Connector::Stripe) => get_outgoing_webhook_request_inner::< + stripe_webhooks::StripeOutgoingWebhook, + >( + outgoing_webhook, payment_response_hash_key + ), + _ => get_outgoing_webhook_request_inner::( + outgoing_webhook, + payment_response_hash_key, + ), + } +} diff --git a/crates/router/src/core/webhooks/webhook_events.rs b/crates/router/src/core/webhooks/webhook_events.rs index 2cb16a53b3a8..c5ba557a78d9 100644 --- a/crates/router/src/core/webhooks/webhook_events.rs +++ b/crates/router/src/core/webhooks/webhook_events.rs @@ -231,7 +231,7 @@ pub async fn retry_delivery_attempt( .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Failed to parse webhook event request information")?; - Box::pin(super::trigger_webhook_and_raise_event( + Box::pin(super::outgoing::trigger_webhook_and_raise_event( state.clone(), business_profile, &key_store, diff --git a/crates/router/src/routes/webhooks.rs b/crates/router/src/routes/webhooks.rs index fee38dbacd2d..a198fe730fea 100644 --- a/crates/router/src/routes/webhooks.rs +++ b/crates/router/src/routes/webhooks.rs @@ -26,7 +26,7 @@ pub async fn receive_incoming_webhook( &req, (), |state, auth, _, req_state| { - webhooks::webhooks_wrapper::( + webhooks::incoming_webhooks_wrapper::( &flow, state.to_owned(), req_state, From 055347606b7d023d732ba6900e153513c4fe2a9c Mon Sep 17 00:00:00 2001 From: likhinbopanna <131246334+likhinbopanna@users.noreply.github.com> Date: Thu, 6 Jun 2024 14:17:45 +0530 Subject: [PATCH 8/9] ci(cypress): Add bank redirects for stripe (#4772) Co-authored-by: Likhin Bopanna --- .../e2e/PaymentTest/00017-BankRedirect.cy.js | 60 +++++++- .../cypress/e2e/PaymentUtils/Adyen.js | 28 +++- .../cypress/e2e/PaymentUtils/Commons.js | 16 +++ .../cypress/e2e/PaymentUtils/Stripe.js | 135 ++++++++++++++++++ .../cypress/e2e/PaymentUtils/Trustpay.js | 1 - .../fixtures/create-connector-body.json | 11 ++ .../cypress/support/redirectionHandler.js | 26 +++- 7 files changed, 273 insertions(+), 4 deletions(-) diff --git a/cypress-tests/cypress/e2e/PaymentTest/00017-BankRedirect.cy.js b/cypress-tests/cypress/e2e/PaymentTest/00017-BankRedirect.cy.js index 0706cf15b13f..f031f15140aa 100644 --- a/cypress-tests/cypress/e2e/PaymentTest/00017-BankRedirect.cy.js +++ b/cypress-tests/cypress/e2e/PaymentTest/00017-BankRedirect.cy.js @@ -301,7 +301,65 @@ describe("Bank Redirect tests", () => { cy.handleBankRedirectRedirection( globalState, payment_method_type, - expected_redirection, + expected_redirection + ); + }); + }); + + context("Przelewy24 Create and Confirm flow test", () => { + let should_continue = true; // variable that will be used to skip tests if a previous test fails + + beforeEach(function () { + if (!should_continue) { + this.skip(); + } + }); + it("create-payment-call-test", () => { + let data = getConnectorDetails(globalState.get("connectorId"))[ + "bank_redirect_pm" + ]["PaymentIntent"]; + let req_data = data["Request"]; + let res_data = data["Response"]; + cy.createPaymentIntentTest( + createPaymentBody, + req_data, + res_data, + "three_ds", + "automatic", + globalState + ); + if (should_continue) + should_continue = utils.should_continue_further(res_data); + }); + + it("payment_methods-call-test", () => { + cy.paymentMethodsCallTest(globalState); + }); + + it("Confirm bank redirect", () => { + let data = getConnectorDetails(globalState.get("connectorId"))[ + "bank_redirect_pm" + ]["przelewy24"]; + let req_data = data["Request"]; + let res_data = data["Response"]; + cy.confirmBankRedirectCallTest( + confirmBody, + req_data, + res_data, + true, + globalState + ); + if (should_continue) + should_continue = utils.should_continue_further(res_data); + }); + + it("Handle bank redirect redirection", () => { + let expected_redirection = confirmBody["return_url"]; + let payment_method_type = globalState.get("paymentMethodType"); + cy.handleBankRedirectRedirection( + globalState, + payment_method_type, + expected_redirection ); }); }); diff --git a/cypress-tests/cypress/e2e/PaymentUtils/Adyen.js b/cypress-tests/cypress/e2e/PaymentUtils/Adyen.js index 19b6f342249a..36c3d2c205de 100644 --- a/cypress-tests/cypress/e2e/PaymentUtils/Adyen.js +++ b/cypress-tests/cypress/e2e/PaymentUtils/Adyen.js @@ -441,7 +441,6 @@ export const connectorDetails = { bank_name: "", bank_account_bic: "", bank_account_iban: "", - preferred_language: "en", country: "DE", }, }, @@ -493,6 +492,33 @@ export const connectorDetails = { }, }, }, + przelewy24: { + Request: { + payment_method: "bank_redirect", + payment_method_type: "przelewy24", + payment_method_data: { + bank_redirect: { + przelewy24: { + bank_name: "citi", + billing_details: { + email: "guest@juspay.in", + }, + }, + }, + }, + }, + Response: { + status: 400, + body: { + error: { + type: "invalid_request", + message: "Payment method type not supported", + code: "HE_03", + reason: "automatic for przelewy24 is not supported by adyen", + }, + }, + }, + }, blik: { Request: { payment_method: "bank_redirect", diff --git a/cypress-tests/cypress/e2e/PaymentUtils/Commons.js b/cypress-tests/cypress/e2e/PaymentUtils/Commons.js index d3228396fc7f..250396cb25f7 100644 --- a/cypress-tests/cypress/e2e/PaymentUtils/Commons.js +++ b/cypress-tests/cypress/e2e/PaymentUtils/Commons.js @@ -481,6 +481,22 @@ export const connectorDetails = { }, }, }), + przelewy24: getCustomExchange({ + Request: { + payment_method: "bank_redirect", + payment_method_type: "przelewy24", + payment_method_data: { + bank_redirect: { + przelewy24: { + bank_name: "citi", + billing_details: { + email: "guest@juspay.in", + }, + }, + }, + }, + }, + }), blikPaymentIntent: getCustomExchange({ Request: { currency: "PLN", diff --git a/cypress-tests/cypress/e2e/PaymentUtils/Stripe.js b/cypress-tests/cypress/e2e/PaymentUtils/Stripe.js index 841c40b87361..7af51bf00758 100644 --- a/cypress-tests/cypress/e2e/PaymentUtils/Stripe.js +++ b/cypress-tests/cypress/e2e/PaymentUtils/Stripe.js @@ -1,3 +1,5 @@ +import { getCustomExchange } from "./Commons"; + const successfulTestCard = "4242424242424242"; const successful3DSCard = "4000002760003184"; @@ -370,4 +372,137 @@ export const connectorDetails = { }, }, }, + bank_redirect_pm: { + PaymentIntent: getCustomExchange({ + Request: { + currency: "EUR", + }, + Response: { + status: 200, + body: { + status: "requires_payment_method", + }, + }, + }), + ideal: { + Request: { + payment_method: "bank_redirect", + payment_method_type: "ideal", + payment_method_data: { + bank_redirect: { + ideal: { + bank_name: "ing", + country: "NL", + }, + }, + }, + }, + Response: { + status: 200, + body: { + status: "requires_customer_action", + }, + }, + }, + giropay: { + Request: { + payment_method: "bank_redirect", + payment_method_type: "giropay", + payment_method_data: { + bank_redirect: { + giropay: { + country: "DE", + }, + }, + }, + }, + Response: { + status: 200, + body: { + status: "requires_customer_action", + }, + }, + }, + sofort: { + Request: { + payment_method: "bank_redirect", + payment_method_type: "sofort", + payment_method_data: { + bank_redirect: { + sofort: { + country: "DE", + preferred_language: "en", + }, + }, + }, + }, + Response: { + status: 200, + body: { + status: "requires_customer_action", + }, + }, + }, + eps: { + Request: { + payment_method: "bank_redirect", + payment_method_type: "eps", + payment_method_data: { + bank_redirect: { + eps: { + bank_name: "bank_austria", + }, + }, + }, + }, + Response: { + status: 200, + body: { + status: "requires_customer_action", + }, + }, + }, + blik: { + Request: { + payment_method: "bank_redirect", + payment_method_type: "blik", + payment_method_data: { + bank_redirect: { + blik: { + blik_code: "777987", + }, + }, + }, + }, + Response: { + status: 200, + body: { + status: "failed", + error_code: "payment_intent_invalid_parameter", + }, + }, + }, + przelewy24: { + Request: { + payment_method: "bank_redirect", + payment_method_type: "przelewy24", + payment_method_data: { + bank_redirect: { + przelewy24: { + bank_name: "citi", + billing_details: { + email: "guest@juspay.in", + }, + }, + }, + }, + }, + Response: { + status: 200, + body: { + status: "requires_customer_action", + }, + }, + }, + }, }; diff --git a/cypress-tests/cypress/e2e/PaymentUtils/Trustpay.js b/cypress-tests/cypress/e2e/PaymentUtils/Trustpay.js index d2a8b956b9d6..338371f72e60 100644 --- a/cypress-tests/cypress/e2e/PaymentUtils/Trustpay.js +++ b/cypress-tests/cypress/e2e/PaymentUtils/Trustpay.js @@ -470,7 +470,6 @@ export const connectorDetails = { bank_name: "", bank_account_bic: "", bank_account_iban: "", - preferred_language: "en", country: "DE", }, }, diff --git a/cypress-tests/cypress/fixtures/create-connector-body.json b/cypress-tests/cypress/fixtures/create-connector-body.json index 9ab253565ed7..0bfa7e49113a 100644 --- a/cypress-tests/cypress/fixtures/create-connector-body.json +++ b/cypress-tests/cypress/fixtures/create-connector-body.json @@ -122,6 +122,17 @@ "maximum_amount": 68607706, "recurring_enabled": true, "installment_payment_enabled": true + }, + { + "payment_method_type": "przelewy24", + "payment_experience": null, + "card_networks": null, + "accepted_currencies": null, + "accepted_countries": null, + "minimum_amount": 1, + "maximum_amount": 68607706, + "recurring_enabled": true, + "installment_payment_enabled": true } ] } diff --git a/cypress-tests/cypress/support/redirectionHandler.js b/cypress-tests/cypress/support/redirectionHandler.js index f6b65d825142..67953a0907ad 100644 --- a/cypress-tests/cypress/support/redirectionHandler.js +++ b/cypress-tests/cypress/support/redirectionHandler.js @@ -193,7 +193,31 @@ function bankRedirectRedirection( break; default: throw new Error( - `Unsupported payment method type: ${payment_method_type}`, + `Unsupported payment method type: ${payment_method_type}` + ); + } + verifyUrl = true; + break; + case "stripe": + switch (payment_method_type) { + case "eps": + cy.get('a[name="success"]').click(); + break; + case "ideal": + cy.get('a[name="success"]').click(); + break; + case "giropay": + cy.get('a[name="success"]').click(); + break; + case "sofort": + cy.get('a[name="success"]').click(); + break; + case "przelewy24": + cy.get('a[name="success"]').click(); + break; + default: + throw new Error( + `Unsupported payment method type: ${payment_method_type}` ); } verifyUrl = true; From 9903119e8b1f2c08ee99e630c1c64cdeb7c34df4 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:44:35 +0000 Subject: [PATCH 9/9] chore(version): 2024.06.06.1 --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 254f7d7709a0..be8e89583228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to HyperSwitch will be documented here. - - - +## 2024.06.06.1 + +### Features + +- **router:** Add an api to migrate the apple pay certificates from connector metadata to `connector_wallets_details` column in merchant connector account ([#4790](https://github.com/juspay/hyperswitch/pull/4790)) ([`7a94237`](https://github.com/juspay/hyperswitch/commit/7a9423759e79167c4093c3482ea56f619cf95635)) + +### Refactors + +- **webhooks:** Extract incoming and outgoing webhooks into separate modules ([#4870](https://github.com/juspay/hyperswitch/pull/4870)) ([`b1cb053`](https://github.com/juspay/hyperswitch/commit/b1cb053a55e9ce4d78f7770b53e39700311d9cd4)) + +**Full Changelog:** [`2024.06.06.0...2024.06.06.1`](https://github.com/juspay/hyperswitch/compare/2024.06.06.0...2024.06.06.1) + +- - - + ## 2024.06.06.0 ### Features