From c1a25b30bd88ab4ad4f40866a16ba5651d711ee3 Mon Sep 17 00:00:00 2001 From: Jagan Date: Fri, 21 Apr 2023 12:51:06 +0530 Subject: [PATCH] feat(connector): [Nuvei] add support for bank redirect Eps, Sofort, Giropay, Ideal (#870) Co-authored-by: Arun Raj M --- Cargo.lock | 157 +++++- crates/api_models/src/payments.rs | 2 + crates/router/Cargo.toml | 1 + crates/router/src/connector/nuvei.rs | 87 ++-- .../src/connector/nuvei/transformers.rs | 408 +++++++++++---- .../src/core/payments/flows/authorize_flow.rs | 2 + .../payments/operations/payment_response.rs | 1 + crates/router/src/types.rs | 6 + crates/router/tests/connectors/main.rs | 2 + crates/router/tests/connectors/nuvei_ui.rs | 177 +++++++ crates/router/tests/connectors/selenium.rs | 469 ++++++++++++++++++ crates/router/tests/connectors/utils.rs | 2 + 12 files changed, 1162 insertions(+), 152 deletions(-) create mode 100644 crates/router/tests/connectors/nuvei_ui.rs create mode 100644 crates/router/tests/connectors/selenium.rs diff --git a/Cargo.lock b/Cargo.lock index 94d137885d..40c8891825 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,7 +226,7 @@ dependencies = [ "serde_urlencoded", "smallvec", "socket2", - "time", + "time 0.3.20", "url", ] @@ -338,7 +338,7 @@ dependencies = [ "serde", "serde_json", "strum", - "time", + "time 0.3.20", "url", "utoipa", ] @@ -550,7 +550,7 @@ dependencies = [ "http", "hyper", "ring", - "time", + "time 0.3.20", "tokio", "tower", "tracing", @@ -709,7 +709,7 @@ dependencies = [ "percent-encoding", "regex", "sha2", - "time", + "time 0.3.20", "tracing", ] @@ -816,7 +816,7 @@ dependencies = [ "itoa", "num-integer", "ryu", - "time", + "time 0.3.20", ] [[package]] @@ -1099,9 +1099,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" dependencies = [ "iana-time-zone", + "js-sys", "num-integer", "num-traits", "serde", + "time 0.1.43", + "wasm-bindgen", "winapi", ] @@ -1191,7 +1194,7 @@ dependencies = [ "signal-hook", "signal-hook-tokio", "thiserror", - "time", + "time 0.3.20", "tokio", ] @@ -1242,7 +1245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ "percent-encoding", - "time", + "time 0.3.20", "version_check", ] @@ -1482,7 +1485,7 @@ dependencies = [ "pq-sys", "r2d2", "serde_json", - "time", + "time 0.3.20", ] [[package]] @@ -1662,6 +1665,28 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "fantoccini" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65f0fbe245d714b596ba5802b46f937f5ce68dcae0f32f9a70b5c3b04d3c6f64" +dependencies = [ + "base64 0.13.1", + "cookie", + "futures-core", + "futures-util", + "http", + "hyper", + "hyper-rustls", + "mime", + "serde", + "serde_json", + "time 0.3.20", + "tokio", + "url", + "webdriver", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -2308,7 +2333,7 @@ dependencies = [ "serde", "serde_json", "thiserror", - "time", + "time 0.3.20", ] [[package]] @@ -3486,8 +3511,9 @@ dependencies = [ "signal-hook-tokio", "storage_models", "strum", + "thirtyfour", "thiserror", - "time", + "time 0.3.20", "tokio", "toml 0.7.3", "url", @@ -3526,7 +3552,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "strum", - "time", + "time 0.3.20", "tokio", "tracing", "tracing-actix-web", @@ -3812,6 +3838,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "serde_repr" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcec881020c684085e55a25f7fd888954d56609ef363479dc5a1305eb0d40cab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.11", +] + [[package]] name = "serde_spanned" version = "0.6.1" @@ -3846,7 +3883,7 @@ dependencies = [ "serde", "serde_json", "serde_with_macros", - "time", + "time 0.3.20", ] [[package]] @@ -3977,7 +4014,7 @@ dependencies = [ "num-bigint", "num-traits", "thiserror", - "time", + "time 0.3.20", ] [[package]] @@ -4046,7 +4083,16 @@ dependencies = [ "serde_json", "strum", "thiserror", - "time", + "time 0.3.20", +] + +[[package]] +name = "stringmatch" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aadc0801d92f0cdc26127c67c4b8766284f52a5ba22894f285e3101fa57d05d" +dependencies = [ + "regex", ] [[package]] @@ -4139,6 +4185,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thirtyfour" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72fc70ad9624071cdd96d034676b84b504bfeb4bee1580df1324c99373ea0ca7" +dependencies = [ + "async-trait", + "base64 0.13.1", + "chrono", + "cookie", + "fantoccini", + "futures", + "http", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_repr", + "stringmatch", + "thirtyfour-macros", + "thiserror", + "tokio", + "url", + "urlparse", +] + +[[package]] +name = "thirtyfour-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cae91d1c7c61ec65817f1064954640ee350a50ae6548ff9a1bdd2489d6ffbb0" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "thiserror" version = "1.0.40" @@ -4169,6 +4253,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "time" version = "0.3.20" @@ -4438,7 +4532,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09d48f71a791638519505cefafe162606f706c25592e4bde4d97600c0195312e" dependencies = [ "crossbeam-channel", - "time", + "time 0.3.20", "tracing-subscriber", ] @@ -4589,6 +4683,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + [[package]] name = "unicode-width" version = "0.1.10" @@ -4619,6 +4719,12 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +[[package]] +name = "urlparse" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110352d4e9076c67839003c7788d8604e24dcded13e0b375af3efaa8cf468517" + [[package]] name = "utoipa" version = "3.3.0" @@ -4691,7 +4797,7 @@ dependencies = [ "git2", "rustc_version", "rustversion", - "time", + "time 0.3.20", ] [[package]] @@ -4829,6 +4935,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webdriver" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9973cb72c8587d5ad5efdb91e663d36177dc37725e6c90ca86c626b0cc45c93f" +dependencies = [ + "base64 0.13.1", + "bytes", + "cookie", + "http", + "log", + "serde", + "serde_derive", + "serde_json", + "time 0.3.20", + "unicode-segmentation", + "url", +] + [[package]] name = "webpki" version = "0.22.0" diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index d0165aa1f1..3cd872522d 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -628,6 +628,7 @@ pub enum WalletData { } #[derive(Eq, PartialEq, Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)] +#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))] pub struct GooglePayWalletData { /// The type of payment method #[serde(rename = "type")] @@ -659,6 +660,7 @@ pub struct MbWayRedirection { } #[derive(Eq, PartialEq, Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)] +#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))] pub struct GooglePayPaymentMethodInfo { /// The name of the card network pub card_network: String, diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index be584c7722..70ae4c33e4 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -102,6 +102,7 @@ time = { version = "0.3.20", features = ["macros"] } tokio = "1.27.0" toml = "0.7.3" wiremock = "0.5" +thirtyfour = "0.31.0" [[bin]] name = "router" diff --git a/crates/router/src/connector/nuvei.rs b/crates/router/src/connector/nuvei.rs index 27cb1b7f77..576c8cece6 100644 --- a/crates/router/src/connector/nuvei.rs +++ b/crates/router/src/connector/nuvei.rs @@ -10,7 +10,7 @@ use ::common_utils::{ use error_stack::{IntoReport, ResultExt}; use transformers as nuvei; -use super::utils::{self, to_boolean, RouterData}; +use super::utils::{self, RouterData}; use crate::{ configs::settings, core::{ @@ -486,35 +486,42 @@ impl ConnectorIntegration { - let integ: Box< - &(dyn ConnectorIntegration< - InitPayment, - types::PaymentsAuthorizeData, - types::PaymentsResponseData, - > + Send - + Sync - + 'static), - > = Box::new(&Self); - let init_data = &types::PaymentsInitRouterData::from(( - &router_data, - router_data.request.clone(), - )); - let init_resp = services::execute_connector_processing_step( - app_state, - integ, - init_data, - payments::CallConnectorAction::Trigger, - ) - .await?; + let (enrolled_for_3ds, related_transaction_id) = + match (router_data.auth_type, router_data.payment_method) { ( - init_resp.request.enrolled_for_3ds, - init_resp.request.related_transaction_id, - ) - } - storage_models::enums::AuthenticationType::NoThreeDs => (false, None), - }; + storage_models::enums::AuthenticationType::ThreeDs, + storage_models::enums::PaymentMethod::Card, + ) => { + let integ: Box< + &(dyn ConnectorIntegration< + InitPayment, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + > + Send + + Sync + + 'static), + > = Box::new(&Self); + let init_data = &types::PaymentsInitRouterData::from(( + &router_data, + router_data.request.clone(), + )); + let init_resp = services::execute_connector_processing_step( + app_state, + integ, + init_data, + payments::CallConnectorAction::Trigger, + ) + .await?; + match init_resp.response { + Ok(types::PaymentsResponseData::ThreeDSEnrollmentResponse { + enrolled_v2, + related_transaction_id, + }) => (enrolled_v2, related_transaction_id), + _ => (false, None), + } + } + _ => (false, None), + }; router_data.request.enrolled_for_3ds = enrolled_for_3ds; router_data.request.related_transaction_id = related_transaction_id; @@ -725,28 +732,12 @@ impl ConnectorIntegration>, pub client_unique_id: String, @@ -95,7 +97,7 @@ pub struct NuveiPaymentFlowRequest { pub merchant_site_id: String, pub client_request_id: String, pub amount: String, - pub currency: String, + pub currency: storage_models::enums::Currency, pub related_transaction_id: Option, pub checksum: String, } @@ -125,22 +127,65 @@ pub struct PaymentOption { pub billing_address: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NuveiBIC { + #[serde(rename = "ABNANL2A")] + Abnamro, + #[serde(rename = "ASNBNL21")] + ASNBank, + #[serde(rename = "BUNQNL2A")] + Bunq, + #[serde(rename = "INGBNL2A")] + Ing, + #[serde(rename = "KNABNL2H")] + Knab, + #[serde(rename = "RABONL2U")] + Rabobank, + #[serde(rename = "RBRBNL21")] + RegioBank, + #[serde(rename = "SNSBNL2A")] + SNSBank, + #[serde(rename = "TRIONL2U")] + TriodosBank, + #[serde(rename = "FVLBNL22")] + VanLanschotBankiers, + #[serde(rename = "MOYONL21")] + Moneyou, +} + +#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AlternativePaymentMethod { pub payment_method: AlternativePaymentMethodType, + #[serde(rename = "BIC")] + pub bank_id: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AlternativePaymentMethodType { #[default] - ApmgwExpresscheckout, + #[serde(rename = "apmgw_expresscheckout")] + Expresscheckout, + #[serde(rename = "apmgw_Giropay")] + Giropay, + #[serde(rename = "apmgw_Sofort")] + Sofort, + #[serde(rename = "apmgw_iDeal")] + Ideal, + #[serde(rename = "apmgw_EPS")] + Eps, } +#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct BillingAddress { - pub email: Secret, + pub email: Option>, + pub first_name: Option>, + pub last_name: Option>, pub country: api_models::enums::CountryCode, } @@ -366,28 +411,34 @@ impl #[derive(Debug, Default)] pub struct NuveiCardDetails { - card: api_models::payments::Card, + card: payments::Card, three_d: Option, } -impl From for NuveiPaymentsRequest { - fn from(gpay_data: api_models::payments::GooglePayWalletData) -> Self { - Self { +impl TryFrom for NuveiPaymentsRequest { + type Error = error_stack::Report; + fn try_from(gpay_data: payments::GooglePayWalletData) -> Result { + Ok(Self { payment_option: PaymentOption { card: Some(Card { external_token: Some(ExternalToken { external_token_provider: ExternalTokenProvider::GooglePay, - mobile_token: gpay_data.tokenization_data.token, + mobile_token: common_utils::ext_traits::Encode::< + payments::GooglePayWalletData, + >::encode_to_string_of_json( + &gpay_data + ) + .change_context(errors::ConnectorError::RequestEncodingFailed)?, }), ..Default::default() }), ..Default::default() }, ..Default::default() - } + }) } } -impl From for NuveiPaymentsRequest { - fn from(apple_pay_data: api_models::payments::ApplePayWalletData) -> Self { +impl From for NuveiPaymentsRequest { + fn from(apple_pay_data: payments::ApplePayWalletData) -> Self { Self { payment_option: PaymentOption { card: Some(Card { @@ -404,6 +455,109 @@ impl From for NuveiPaymentsRequest { } } +impl TryFrom for NuveiBIC { + type Error = error_stack::Report; + fn try_from(bank: api_models::enums::BankNames) -> Result { + match bank { + api_models::enums::BankNames::AbnAmro => Ok(Self::Abnamro), + api_models::enums::BankNames::AsnBank => Ok(Self::ASNBank), + api_models::enums::BankNames::Bunq => Ok(Self::Bunq), + api_models::enums::BankNames::Ing => Ok(Self::Ing), + api_models::enums::BankNames::Knab => Ok(Self::Knab), + api_models::enums::BankNames::Rabobank => Ok(Self::Rabobank), + api_models::enums::BankNames::SnsBank => Ok(Self::SNSBank), + api_models::enums::BankNames::TriodosBank => Ok(Self::TriodosBank), + api_models::enums::BankNames::VanLanschot => Ok(Self::VanLanschotBankiers), + api_models::enums::BankNames::Moneyou => Ok(Self::Moneyou), + _ => Err(errors::ConnectorError::FlowNotSupported { + flow: bank.to_string(), + connector: "Nuvei".to_string(), + } + .into()), + } + } +} + +impl + ForeignTryFrom<( + AlternativePaymentMethodType, + Option, + &types::RouterData, + )> for NuveiPaymentsRequest +{ + type Error = error_stack::Report; + fn foreign_try_from( + data: ( + AlternativePaymentMethodType, + Option, + &types::RouterData, + ), + ) -> Result { + let (payment_method, redirect, item) = data; + let (billing_address, bank_id) = match (&payment_method, redirect) { + (AlternativePaymentMethodType::Expresscheckout, _) => ( + Some(BillingAddress { + email: Some(item.request.get_email()?), + country: item.get_billing_country()?, + ..Default::default() + }), + None, + ), + (AlternativePaymentMethodType::Giropay, _) => ( + Some(BillingAddress { + email: Some(item.request.get_email()?), + country: item.get_billing_country()?, + ..Default::default() + }), + None, + ), + (AlternativePaymentMethodType::Sofort, _) | (AlternativePaymentMethodType::Eps, _) => { + let address = item.get_billing_address()?; + ( + Some(BillingAddress { + first_name: Some(address.get_first_name()?.clone()), + last_name: Some(address.get_last_name()?.clone()), + email: Some(item.request.get_email()?), + country: item.get_billing_country()?, + }), + None, + ) + } + ( + AlternativePaymentMethodType::Ideal, + Some(payments::BankRedirectData::Ideal { bank_name, .. }), + ) => { + let address = item.get_billing_address()?; + ( + Some(BillingAddress { + first_name: Some(address.get_first_name()?.clone()), + last_name: Some(address.get_last_name()?.clone()), + email: Some(item.request.get_email()?), + country: item.get_billing_country()?, + }), + Some(NuveiBIC::try_from(bank_name)?), + ) + } + _ => Err(errors::ConnectorError::NotSupported { + payment_method: "Bank Redirect".to_string(), + connector: "Nuvei", + payment_experience: "Redirection".to_string(), + })?, + }; + Ok(Self { + payment_option: PaymentOption { + alternative_payment_method: Some(AlternativePaymentMethod { + payment_method, + bank_id, + }), + ..Default::default() + }, + billing_address, + ..Default::default() + }) + } +} + impl TryFrom<( &types::RouterData, @@ -421,23 +575,13 @@ impl let request_data = match item.request.payment_method_data.clone() { api::PaymentMethodData::Card(card) => get_card_info(item, &card), api::PaymentMethodData::Wallet(wallet) => match wallet { - api_models::payments::WalletData::GooglePay(gpay_data) => Ok(Self::from(gpay_data)), - api_models::payments::WalletData::ApplePay(apple_pay_data) => { - Ok(Self::from(apple_pay_data)) - } - api_models::payments::WalletData::PaypalRedirect(_) => Ok(Self { - payment_option: PaymentOption { - alternative_payment_method: Some(AlternativePaymentMethod { - payment_method: AlternativePaymentMethodType::ApmgwExpresscheckout, - }), - ..Default::default() - }, - billing_address: Some(BillingAddress { - email: item.request.get_email()?, - country: item.get_billing_country()?, - }), - ..Default::default() - }), + payments::WalletData::GooglePay(gpay_data) => Self::try_from(gpay_data), + payments::WalletData::ApplePay(apple_pay_data) => Ok(Self::from(apple_pay_data)), + payments::WalletData::PaypalRedirect(_) => Self::foreign_try_from(( + AlternativePaymentMethodType::Expresscheckout, + None, + item, + )), _ => Err(errors::ConnectorError::NotSupported { payment_method: "Wallet".to_string(), connector: "Nuvei", @@ -445,11 +589,39 @@ impl } .into()), }, + api::PaymentMethodData::BankRedirect(redirect) => match redirect { + payments::BankRedirectData::Eps { .. } => Self::foreign_try_from(( + AlternativePaymentMethodType::Eps, + Some(redirect), + item, + )), + payments::BankRedirectData::Giropay { .. } => Self::foreign_try_from(( + AlternativePaymentMethodType::Giropay, + Some(redirect), + item, + )), + payments::BankRedirectData::Ideal { .. } => Self::foreign_try_from(( + AlternativePaymentMethodType::Ideal, + Some(redirect), + item, + )), + payments::BankRedirectData::Sofort { .. } => Self::foreign_try_from(( + AlternativePaymentMethodType::Sofort, + Some(redirect), + item, + )), + _ => Err(errors::ConnectorError::NotSupported { + payment_method: "Bank Redirect".to_string(), + connector: "Nuvei", + payment_experience: "RedirectToUrl".to_string(), + } + .into()), + }, _ => Err(errors::ConnectorError::NotImplemented("Payment methods".to_string()).into()), }?; let request = Self::try_from(NuveiPaymentRequestData { - amount: item.request.amount.clone().to_string(), - currency: item.request.currency.clone().to_string(), + amount: utils::to_currency_base_unit(item.request.amount, item.request.currency)?, + currency: item.request.currency, connector_auth_type: item.connector_auth_type.clone(), client_request_id: item.attempt_id.clone(), session_token: data.1, @@ -461,6 +633,7 @@ impl user_token_id: request_data.user_token_id, related_transaction_id: request_data.related_transaction_id, payment_option: request_data.payment_option, + billing_address: request_data.billing_address, ..request }) } @@ -468,7 +641,7 @@ impl fn get_card_info( item: &types::RouterData, - card_details: &api_models::payments::Card, + card_details: &payments::Card, ) -> Result> { let browser_info = item.request.get_browser_info()?; let related_transaction_id = if item.is_three_ds() { @@ -493,8 +666,8 @@ fn get_card_info( match item.request.setup_mandate_details.clone() { Some(mandate_data) => { let details = match mandate_data.mandate_type { - api_models::payments::MandateType::SingleUse(details) => details, - api_models::payments::MandateType::MultiUse(details) => { + payments::MandateType::SingleUse(details) => details, + payments::MandateType::MultiUse(details) => { details.ok_or(errors::ConnectorError::MissingRequiredField { field_name: "mandate_data.mandate_type.multi_use", })? @@ -593,8 +766,8 @@ impl TryFrom<(&types::PaymentsCompleteAuthorizeRouterData, String)> for NuveiPay )), }?; let request = Self::try_from(NuveiPaymentRequestData { - amount: item.request.amount.clone().to_string(), - currency: item.request.currency.clone().to_string(), + amount: utils::to_currency_base_unit(item.request.amount, item.request.currency)?, + currency: item.request.currency, connector_auth_type: item.connector_auth_type.clone(), client_request_id: item.attempt_id.clone(), session_token: data.1, @@ -640,7 +813,7 @@ impl TryFrom for NuveiPaymentsRequest { merchant_site_id, client_request_id, request.amount.clone(), - request.currency.clone(), + request.currency.clone().to_string(), time_stamp, merchant_secret, ])?, @@ -673,7 +846,7 @@ impl TryFrom for NuveiPaymentFlowRequest { merchant_site_id, client_request_id, request.amount.clone(), - request.currency.clone(), + request.currency.clone().to_string(), request.related_transaction_id.clone().unwrap_or_default(), time_stamp, merchant_secret, @@ -685,11 +858,10 @@ impl TryFrom for NuveiPaymentFlowRequest { } } -/// Common request handler for all the flows that has below fields in common #[derive(Debug, Clone, Default)] pub struct NuveiPaymentRequestData { pub amount: String, - pub currency: String, + pub currency: storage_models::enums::Currency, pub related_transaction_id: Option, pub client_request_id: String, pub connector_auth_type: types::ConnectorAuthType, @@ -704,7 +876,7 @@ impl TryFrom<&types::PaymentsCaptureRouterData> for NuveiPaymentFlowRequest { client_request_id: item.attempt_id.clone(), connector_auth_type: item.connector_auth_type.clone(), amount: item.request.amount_to_capture.to_string(), - currency: item.request.currency.to_string(), + currency: item.request.currency, related_transaction_id: Some(item.request.connector_transaction_id.clone()), ..Default::default() }) @@ -717,7 +889,7 @@ impl TryFrom<&types::RefundExecuteRouterData> for NuveiPaymentFlowRequest { client_request_id: item.attempt_id.clone(), connector_auth_type: item.connector_auth_type.clone(), amount: item.request.amount.to_string(), - currency: item.request.currency.to_string(), + currency: item.request.currency, related_transaction_id: Some(item.request.connector_transaction_id.clone()), ..Default::default() }) @@ -741,7 +913,7 @@ impl TryFrom<&types::PaymentsCancelRouterData> for NuveiPaymentFlowRequest { client_request_id: item.attempt_id.clone(), connector_auth_type: item.connector_auth_type.clone(), amount: item.request.get_amount()?.to_string(), - currency: item.request.get_currency()?.to_string(), + currency: item.request.get_currency()?, related_transaction_id: Some(item.request.connector_transaction_id.clone()), ..Default::default() }) @@ -868,14 +1040,11 @@ fn get_payment_status(response: &NuveiPaymentsResponse) -> enums::AttemptStatus NuveiTransactionStatus::Declined | NuveiTransactionStatus::Error => { match response.transaction_type { Some(NuveiTransactionType::Auth) => enums::AttemptStatus::AuthorizationFailed, - Some(NuveiTransactionType::Sale) | Some(NuveiTransactionType::Settle) => { - enums::AttemptStatus::Failure - } Some(NuveiTransactionType::Void) => enums::AttemptStatus::VoidFailed, Some(NuveiTransactionType::Auth3D) => { enums::AttemptStatus::AuthenticationFailed } - _ => enums::AttemptStatus::Pending, + _ => enums::AttemptStatus::Failure, } } NuveiTransactionStatus::Processing => enums::AttemptStatus::Pending, @@ -888,16 +1057,58 @@ fn get_payment_status(response: &NuveiPaymentsResponse) -> enums::AttemptStatus } } +fn build_error_response( + response: &NuveiPaymentsResponse, + http_code: u16, +) -> Option> { + match response.status { + NuveiPaymentStatus::Error => Some(get_error_response( + response.err_code, + &response.reason, + http_code, + )), + _ => { + let err = Some(get_error_response( + response.gw_error_code, + &response.gw_error_reason, + http_code, + )); + match response.transaction_status { + Some(NuveiTransactionStatus::Error) => err, + _ => match response + .gw_error_reason + .as_ref() + .map(|r| r.eq("Missing argument")) + { + Some(true) => err, + _ => None, + }, + } + } + } +} + +pub trait NuveiPaymentsGenericResponse {} + +impl NuveiPaymentsGenericResponse for api::Authorize {} +impl NuveiPaymentsGenericResponse for api::CompleteAuthorize {} +impl NuveiPaymentsGenericResponse for api::Void {} +impl NuveiPaymentsGenericResponse for api::PSync {} +impl NuveiPaymentsGenericResponse for api::Capture {} + impl TryFrom> for types::RouterData +where + F: NuveiPaymentsGenericResponse, { type Error = error_stack::Report; fn try_from( item: types::ResponseRouterData, ) -> Result { let redirection_data = match item.data.payment_method { - storage_models::enums::PaymentMethod::Wallet => item + storage_models::enums::PaymentMethod::Wallet + | storage_models::enums::PaymentMethod::BankRedirect => item .response .payment_option .as_ref() @@ -920,46 +1131,65 @@ impl let response = item.response; Ok(Self { status: get_payment_status(&response), - response: match response.status { - NuveiPaymentStatus::Error => { - get_error_response(response.err_code, response.reason, item.http_code) - } - _ => match response.transaction_status { - Some(NuveiTransactionStatus::Error) => get_error_response( - response.gw_error_code, - response.gw_error_reason, - item.http_code, - ), - _ => Ok(types::PaymentsResponseData::TransactionResponse { - resource_id: response - .transaction_id - .map_or(response.order_id, Some) // For paypal there will be no transaction_id, only order_id will be present - .map(types::ResponseId::ConnectorTransactionId) - .ok_or(errors::ConnectorError::MissingConnectorTransactionID)?, - redirection_data, - mandate_reference: response - .payment_option - .and_then(|po| po.user_payment_option_id), - // we don't need to save session token for capture, void flow so ignoring if it is not present - connector_metadata: if let Some(token) = response.session_token { - Some( - serde_json::to_value(NuveiMeta { - session_token: token, - }) - .into_report() - .change_context(errors::ConnectorError::ResponseHandlingFailed)?, - ) - } else { - None - }, - }), - }, + response: if let Some(err) = build_error_response(&response, item.http_code) { + err + } else { + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: response + .transaction_id + .map_or(response.order_id, Some) // For paypal there will be no transaction_id, only order_id will be present + .map(types::ResponseId::ConnectorTransactionId) + .ok_or(errors::ConnectorError::MissingConnectorTransactionID)?, + redirection_data, + mandate_reference: response + .payment_option + .and_then(|po| po.user_payment_option_id), + // we don't need to save session token for capture, void flow so ignoring if it is not present + connector_metadata: if let Some(token) = response.session_token { + Some( + serde_json::to_value(NuveiMeta { + session_token: token, + }) + .into_report() + .change_context(errors::ConnectorError::ResponseHandlingFailed)?, + ) + } else { + None + }, + }) }, ..item.data }) } } +impl TryFrom> + for types::PaymentsInitRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::PaymentsInitResponseRouterData, + ) -> Result { + let response = item.response; + let is_enrolled_for_3ds = response + .clone() + .payment_option + .and_then(|po| po.card) + .and_then(|c| c.three_d) + .and_then(|t| t.v2supported) + .map(utils::to_boolean) + .unwrap_or_default(); + Ok(Self { + status: get_payment_status(&response), + response: Ok(types::PaymentsResponseData::ThreeDSEnrollmentResponse { + enrolled_v2: is_enrolled_for_3ds, + related_transaction_id: response.transaction_id, + }), + ..item.data + }) + } +} + impl From for enums::RefundStatus { fn from(item: NuveiTransactionStatus) -> Self { match item { @@ -1022,11 +1252,11 @@ fn get_refund_response( .unwrap_or(enums::RefundStatus::Failure); match response.status { NuveiPaymentStatus::Error => { - get_error_response(response.err_code, response.reason, http_code) + get_error_response(response.err_code, &response.reason, http_code) } _ => match response.transaction_status { Some(NuveiTransactionStatus::Error) => { - get_error_response(response.gw_error_code, response.gw_error_reason, http_code) + get_error_response(response.gw_error_code, &response.gw_error_reason, http_code) } _ => Ok(types::RefundsResponseData { connector_refund_id: txn_id, @@ -1038,14 +1268,16 @@ fn get_refund_response( fn get_error_response( error_code: Option, - error_msg: Option, + error_msg: &Option, http_code: u16, ) -> Result { Err(types::ErrorResponse { code: error_code .map(|c| c.to_string()) .unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()), - message: error_msg.unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()), + message: error_msg + .clone() + .unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()), reason: None, status_code: http_code, }) diff --git a/crates/router/src/core/payments/flows/authorize_flow.rs b/crates/router/src/core/payments/flows/authorize_flow.rs index 9a2434fd4e..b5432f0fb2 100644 --- a/crates/router/src/core/payments/flows/authorize_flow.rs +++ b/crates/router/src/core/payments/flows/authorize_flow.rs @@ -117,8 +117,10 @@ impl types::PaymentsAuthorizeRouterData { .execute_pretasks(self, state) .await .map_err(|error| error.to_payment_failed_response())?; + logger::debug!(completed_pre_tasks=?true); if self.should_proceed_with_authorize() { self.decide_authentication_type(); + logger::debug!(auth_type=?self.auth_type); let resp = services::execute_connector_processing_step( state, connector_integration, diff --git a/crates/router/src/core/payments/operations/payment_response.rs b/crates/router/src/core/payments/operations/payment_response.rs index 7b17071ec0..a4849b5dc8 100644 --- a/crates/router/src/core/payments/operations/payment_response.rs +++ b/crates/router/src/core/payments/operations/payment_response.rs @@ -387,6 +387,7 @@ async fn payment_response_update_tracker( types::PaymentsResponseData::SessionResponse { .. } => (None, None), types::PaymentsResponseData::SessionTokenResponse { .. } => (None, None), types::PaymentsResponseData::TokenizationResponse { .. } => (None, None), + types::PaymentsResponseData::ThreeDSEnrollmentResponse { .. } => (None, None), }, }; diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index ce51c47b53..fa7a69f7ce 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -51,6 +51,8 @@ pub type PaymentsSyncResponseRouterData = ResponseRouterData; pub type PaymentsSessionResponseRouterData = ResponseRouterData; +pub type PaymentsInitResponseRouterData = + ResponseRouterData; pub type PaymentsCaptureResponseRouterData = ResponseRouterData; pub type TokenizationResponseRouterData = ResponseRouterData< @@ -283,6 +285,10 @@ pub enum PaymentsResponseData { TokenizationResponse { token: String, }, + ThreeDSEnrollmentResponse { + enrolled_v2: bool, + related_transaction_id: Option, + }, } #[derive(Debug, Clone, Default)] diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index 5d80c9f734..55dbea1d5c 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -18,11 +18,13 @@ mod mollie; mod multisafepay; mod nexinets; mod nuvei; +mod nuvei_ui; mod opennode; mod payeezy; mod paypal; mod payu; mod rapyd; +mod selenium; mod shift4; mod stripe; mod trustpay; diff --git a/crates/router/tests/connectors/nuvei_ui.rs b/crates/router/tests/connectors/nuvei_ui.rs new file mode 100644 index 0000000000..784bc7ff8b --- /dev/null +++ b/crates/router/tests/connectors/nuvei_ui.rs @@ -0,0 +1,177 @@ +use serial_test::serial; +use thirtyfour::{prelude::*, WebDriver}; + +use crate::{selenium::*, tester}; + +struct NuveiSeleniumTest; + +impl SeleniumTest for NuveiSeleniumTest {} + +async fn should_make_nuvei_3ds_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_redirection_payment(c, vec![ + Event::Trigger(Trigger::Goto("https://hs-payment-tests.w3spaces.com?pay-mode=pm-card&cname=CL-BRW1&ccnum=4000027891380961&expmonth=10&expyear=25&cvv=123&amount=200&country=US¤cy=USD")), + Event::Assert(Assert::IsPresent("Exp Year")), + Event::Trigger(Trigger::Click(By::Id("card-submit-btn"))), + Event::Trigger(Trigger::Query(By::ClassName("title"))), + Event::Assert(Assert::Eq(Selector::Title, "ThreeDS ACS Emulator - Challenge Page")), + Event::Trigger(Trigger::Click(By::Id("btn1"))), + Event::Trigger(Trigger::Click(By::Id("btn5"))), + Event::Assert(Assert::IsPresent("Google")), + Event::Assert(Assert::Contains(Selector::QueryParamStr, "status=succeeded")), + + ]).await?; + Ok(()) +} + +async fn should_make_nuvei_3ds_mandate_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_redirection_payment(c, vec![ + Event::Trigger(Trigger::Goto("https://hs-payment-tests.w3spaces.com?pay-mode=pm-card&cname=CL-BRW1&ccnum=4000027891380961&expmonth=10&expyear=25&cvv=123&amount=200&country=US¤cy=USD&setup_future_usage=off_session&mandate_data[customer_acceptance][acceptance_type]=offline&mandate_data[customer_acceptance][accepted_at]=1963-05-03T04:07:52.723Z&mandate_data[customer_acceptance][online][ip_address]=in%20sit&mandate_data[customer_acceptance][online][user_agent]=amet%20irure%20esse&mandate_data[mandate_type][multi_use][amount]=7000&mandate_data[mandate_type][multi_use][currency]=USD&mandate_data[mandate_type][multi_use][start_date]=2022-09-10T00:00:00Z&mandate_data[mandate_type][multi_use][end_date]=2023-09-10T00:00:00Z&mandate_data[mandate_type][multi_use][metadata][frequency]=13")), + Event::Trigger(Trigger::Click(By::Id("card-submit-btn"))), + Event::Trigger(Trigger::Query(By::ClassName("title"))), + Event::Assert(Assert::Eq(Selector::Title, "ThreeDS ACS Emulator - Challenge Page")), + Event::Trigger(Trigger::Click(By::Id("btn1"))), + Event::Trigger(Trigger::Click(By::Id("btn5"))), + Event::Assert(Assert::IsPresent("Google")), + Event::Assert(Assert::Contains(Selector::QueryParamStr, "status=succeeded")), + + ]).await?; + Ok(()) +} + +async fn should_make_nuvei_gpay_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_gpay_payment(c, + "https://hs-payment-tests.w3spaces.com?pay-mode=pm-gpay&gatewayname=nuveidigital&gatewaymerchantid=googletest&amount=10.00&country=IN¤cy=USD", + vec![ + Event::Assert(Assert::IsPresent("succeeded")), + ]).await?; + Ok(()) +} + +async fn should_make_nuvei_pypl_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_paypal_payment(c, + "https://hs-payment-tests.w3spaces.com?pay-mode=pypl-redirect&amount=12.00&country=US¤cy=USD", + vec![ + Event::Assert(Assert::IsPresent("Your transaction has been successfully executed.")), + ]).await?; + Ok(()) +} + +async fn should_make_nuvei_giropay_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_redirection_payment(c, vec![ + Event::Trigger(Trigger::Goto("https://hs-payment-tests.w3spaces.com?pay-mode=bank-redirect&amount=1.00&country=DE¤cy=EUR&paymentmethod=giropay")), + Event::Trigger(Trigger::Click(By::Id("bank-redirect-btn"))), + Event::Assert(Assert::IsPresent("You are about to make a payment using the Giropay service.")), + Event::Trigger(Trigger::Click(By::Id("ctl00_ctl00_mainContent_btnConfirm"))), + Event::RunIf(Assert::IsPresent("Bank suchen"), vec![ + Event::Trigger(Trigger::SendKeys(By::Id("bankSearch"), "GIROPAY Testbank 1")), + Event::Trigger(Trigger::Click(By::Id("GIROPAY Testbank 1"))), + ]), + Event::Assert(Assert::IsPresent("GIROPAY Testbank 1")), + Event::Trigger(Trigger::Click(By::Css("button[name='claimCheckoutButton']"))), + Event::Assert(Assert::IsPresent("sandbox.paydirekt")), + Event::Trigger(Trigger::Click(By::Id("submitButton"))), + Event::Trigger(Trigger::Sleep(5)), + Event::Trigger(Trigger::SwitchTab(Position::Next)), + Event::Assert(Assert::IsPresent("Sicher bezahlt!")), + Event::Assert(Assert::IsPresent("Your transaction")) // Transaction succeeds sometimes and pending sometimes + ]).await?; + Ok(()) +} + +async fn should_make_nuvei_ideal_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_redirection_payment(c, vec![ + Event::Trigger(Trigger::Goto("https://hs-payment-tests.w3spaces.com?pay-mode=bank-redirect&amount=10.00&country=NL¤cy=EUR&paymentmethod=ideal&processingbank=ing")), + Event::Trigger(Trigger::Click(By::Id("bank-redirect-btn"))), + Event::Assert(Assert::IsPresent("Your account will be debited:")), + Event::Trigger(Trigger::SelectOption(By::Id("ctl00_ctl00_mainContent_ServiceContent_ddlBanks"), "ING Simulator")), + Event::Trigger(Trigger::Click(By::Id("ctl00_ctl00_mainContent_btnConfirm"))), + Event::Assert(Assert::IsPresent("IDEALFORTIS")), + Event::Trigger(Trigger::Sleep(5)), + Event::Trigger(Trigger::Click(By::Id("ctl00_mainContent_btnGo"))), + Event::Assert(Assert::IsPresent("Your transaction")),// Transaction succeeds sometimes and pending sometimes + ]).await?; + Ok(()) +} + +async fn should_make_nuvei_sofort_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_redirection_payment(c, vec![ + Event::Trigger(Trigger::Goto("https://hs-payment-tests.w3spaces.com?pay-mode=bank-redirect&amount=10.00&country=DE¤cy=EUR&paymentmethod=sofort")), + Event::Trigger(Trigger::Click(By::Id("bank-redirect-btn"))), + Event::Assert(Assert::IsPresent("SOFORT")), + Event::Trigger(Trigger::ChangeQueryParam("sender_holder", "John Doe")), + Event::Trigger(Trigger::Click(By::Id("ctl00_mainContent_btnGo"))), + Event::Assert(Assert::IsPresent("Your transaction")),// Transaction succeeds sometimes and pending sometimes + ]).await?; + Ok(()) +} + +async fn should_make_nuvei_eps_payment(c: WebDriver) -> Result<(), WebDriverError> { + let conn = NuveiSeleniumTest {}; + conn.make_redirection_payment(c, vec![ + Event::Trigger(Trigger::Goto("https://hs-payment-tests.w3spaces.com?pay-mode=bank-redirect&amount=10.00&country=AT¤cy=EUR&paymentmethod=eps&processingbank=ing")), + Event::Trigger(Trigger::Click(By::Id("bank-redirect-btn"))), + Event::Assert(Assert::IsPresent("You are about to make a payment using the EPS service.")), + Event::Trigger(Trigger::SendKeys(By::Id("ctl00_ctl00_mainContent_ServiceContent_txtCustomerName"), "John Doe")), + Event::Trigger(Trigger::Click(By::Id("ctl00_ctl00_mainContent_btnConfirm"))), + Event::Assert(Assert::IsPresent("Simulator")), + Event::Trigger(Trigger::SelectOption(By::Css("select[name='result']"), "Succeeded")), + Event::Trigger(Trigger::Click(By::Id("submitbutton"))), + Event::Assert(Assert::IsPresent("Your transaction")),// Transaction succeeds sometimes and pending sometimes + ]).await?; + Ok(()) +} + +#[test] +#[serial] +fn should_make_nuvei_3ds_payment_test() { + tester!(should_make_nuvei_3ds_payment, "firefox"); +} + +#[test] +#[serial] +fn should_make_nuvei_3ds_mandate_payment_test() { + tester!(should_make_nuvei_3ds_mandate_payment, "firefox"); +} + +#[test] +#[serial] +fn should_make_nuvei_gpay_payment_test() { + tester!(should_make_nuvei_gpay_payment, "firefox"); +} + +#[test] +#[serial] +fn should_make_nuvei_pypl_payment_test() { + tester!(should_make_nuvei_pypl_payment, "firefox"); +} + +#[test] +#[serial] +fn should_make_nuvei_giropay_payment_test() { + tester!(should_make_nuvei_giropay_payment, "firefox"); +} + +#[test] +#[serial] +fn should_make_nuvei_ideal_payment_test() { + tester!(should_make_nuvei_ideal_payment, "firefox"); +} + +#[test] +#[serial] +fn should_make_nuvei_sofort_payment_test() { + tester!(should_make_nuvei_sofort_payment, "firefox"); +} + +#[test] +#[serial] +fn should_make_nuvei_eps_payment_test() { + tester!(should_make_nuvei_eps_payment, "firefox"); +} diff --git a/crates/router/tests/connectors/selenium.rs b/crates/router/tests/connectors/selenium.rs new file mode 100644 index 0000000000..0114d510b3 --- /dev/null +++ b/crates/router/tests/connectors/selenium.rs @@ -0,0 +1,469 @@ +use std::{collections::HashMap, env, path::MAIN_SEPARATOR, time::Duration}; + +use actix_web::cookie::SameSite; +use async_trait::async_trait; +use futures::Future; +use thirtyfour::{components::SelectElement, prelude::*, WebDriver}; + +pub enum Event<'a> { + RunIf(Assert<'a>, Vec>), + EitherOr(Assert<'a>, Vec>, Vec>), + Assert(Assert<'a>), + Trigger(Trigger<'a>), +} + +#[allow(dead_code)] +pub enum Trigger<'a> { + Goto(&'a str), + Click(By), + ClickNth(By, usize), + SelectOption(By, &'a str), + ChangeQueryParam(&'a str, &'a str), + SwitchTab(Position), + SwitchFrame(By), + Find(By), + Query(By), + SendKeys(By, &'a str), + Sleep(u64), +} + +pub enum Position { + Prev, + Next, +} +pub enum Selector { + Title, + QueryParamStr, +} + +pub enum Assert<'a> { + Eq(Selector, &'a str), + Contains(Selector, &'a str), + IsPresent(&'a str), +} + +#[async_trait] +pub trait SeleniumTest { + async fn complete_actions( + &self, + driver: &WebDriver, + actions: Vec>, + ) -> Result<(), WebDriverError> { + for action in actions { + match action { + Event::Assert(assert) => match assert { + Assert::Contains(selector, text) => match selector { + Selector::QueryParamStr => { + let url = driver.current_url().await?; + assert!(url.query().unwrap().contains(text)) + } + _ => assert!(driver.title().await?.contains(text)), + }, + Assert::Eq(_selector, text) => assert_eq!(driver.title().await?, text), + Assert::IsPresent(text) => { + assert!(is_text_present(driver, text).await?) + } + }, + Event::RunIf(con_event, events) => match con_event { + Assert::Contains(selector, text) => match selector { + Selector::QueryParamStr => { + let url = driver.current_url().await?; + if url.query().unwrap().contains(text) { + self.complete_actions(driver, events).await?; + } + } + _ => assert!(driver.title().await?.contains(text)), + }, + Assert::Eq(_selector, text) => { + if text == driver.title().await? { + self.complete_actions(driver, events).await?; + } + } + Assert::IsPresent(text) => { + if is_text_present(driver, text).await.is_ok() { + self.complete_actions(driver, events).await?; + } + } + }, + Event::EitherOr(con_event, success, failure) => match con_event { + Assert::Contains(selector, text) => match selector { + Selector::QueryParamStr => { + let url = driver.current_url().await?; + self.complete_actions( + driver, + if url.query().unwrap().contains(text) { + success + } else { + failure + }, + ) + .await?; + } + _ => assert!(driver.title().await?.contains(text)), + }, + Assert::Eq(_selector, text) => { + self.complete_actions( + driver, + if text == driver.title().await? { + success + } else { + failure + }, + ) + .await?; + } + Assert::IsPresent(text) => { + self.complete_actions( + driver, + if is_text_present(driver, text).await.is_ok() { + success + } else { + failure + }, + ) + .await?; + } + }, + Event::Trigger(trigger) => match trigger { + Trigger::Goto(url) => { + driver.goto(url).await?; + let hs_base_url = + env::var("HS_BASE_URL").unwrap_or("http://localhost:8080".to_string()); //Issue: #924 + let hs_api_key = + env::var("HS_API_KEY").expect("Hyperswitch user API key not present"); //Issue: #924 + driver + .add_cookie(new_cookie("hs_base_url", hs_base_url).clone()) + .await?; + driver + .add_cookie(new_cookie("hs_api_key", hs_api_key).clone()) + .await?; + } + Trigger::Click(by) => { + let ele = driver.query(by).first().await?; + ele.wait_until().displayed().await?; + ele.wait_until().clickable().await?; + ele.click().await?; + } + Trigger::ClickNth(by, n) => { + let ele = driver.query(by).all().await?.into_iter().nth(n).unwrap(); + ele.wait_until().displayed().await?; + ele.wait_until().clickable().await?; + ele.click().await?; + } + Trigger::Find(by) => { + driver.find(by).await?; + } + Trigger::Query(by) => { + driver.query(by).first().await?; + } + Trigger::SendKeys(by, input) => { + let ele = driver.query(by).first().await?; + ele.wait_until().displayed().await?; + ele.send_keys(&input).await?; + } + Trigger::SelectOption(by, input) => { + let ele = driver.query(by).first().await?; + let select_element = SelectElement::new(&ele).await?; + select_element.select_by_partial_text(input).await?; + } + Trigger::ChangeQueryParam(param, value) => { + let mut url = driver.current_url().await?; + let mut hash_query: HashMap = + url.query_pairs().into_owned().collect(); + hash_query.insert(param.to_string(), value.to_string()); + let url_str = serde_urlencoded::to_string(hash_query) + .expect("Query Param update failed"); + url.set_query(Some(&url_str)); + driver.goto(url.as_str()).await?; + } + Trigger::Sleep(seconds) => { + tokio::time::sleep(Duration::from_secs(seconds)).await; + } + Trigger::SwitchTab(position) => match position { + Position::Next => { + let windows = driver.windows().await?; + if let Some(window) = windows.iter().rev().next() { + driver.switch_to_window(window.to_owned()).await?; + } + } + Position::Prev => { + let windows = driver.windows().await?; + if let Some(window) = windows.into_iter().next() { + driver.switch_to_window(window.to_owned()).await?; + } + } + }, + Trigger::SwitchFrame(by) => { + let iframe = driver.query(by).first().await?; + iframe.wait_until().displayed().await?; + iframe.clone().enter_frame().await?; + } + }, + } + } + Ok(()) + } + + async fn process_payment(&self, _f: F) -> Result<(), WebDriverError> + where + F: FnOnce(WebDriver) -> Fut + Send, + Fut: Future> + Send, + { + let _browser = env::var("HS_TEST_BROWSER").unwrap_or("chrome".to_string()); //Issue: #924 + Ok(()) + } + async fn make_redirection_payment( + &self, + c: WebDriver, + actions: Vec>, + ) -> Result<(), WebDriverError> { + self.complete_actions(&c, actions).await + } + async fn make_gpay_payment( + &self, + c: WebDriver, + url: &str, + actions: Vec>, + ) -> Result<(), WebDriverError> { + let (email, pass) = ( + &get_env("GMAIL_EMAIL").clone(), + &get_env("GMAIL_PASS").clone(), + ); + let default_actions = vec![ + Event::Trigger(Trigger::Goto(url)), + Event::Trigger(Trigger::Click(By::Css("#gpay-btn button"))), + Event::Trigger(Trigger::SwitchTab(Position::Next)), + Event::RunIf( + Assert::IsPresent("Sign in"), + vec![ + Event::Trigger(Trigger::SendKeys(By::Id("identifierId"), email)), + Event::Trigger(Trigger::ClickNth(By::Tag("button"), 2)), + Event::EitherOr( + Assert::IsPresent("Welcome"), + vec![ + Event::Trigger(Trigger::SendKeys(By::Name("Passwd"), pass)), + Event::Trigger(Trigger::Sleep(2)), + Event::Trigger(Trigger::Click(By::Id("passwordNext"))), + ], + vec![ + Event::Trigger(Trigger::SendKeys(By::Id("identifierId"), email)), + Event::Trigger(Trigger::ClickNth(By::Tag("button"), 2)), + Event::Trigger(Trigger::SendKeys(By::Name("Passwd"), pass)), + Event::Trigger(Trigger::Sleep(2)), + Event::Trigger(Trigger::Click(By::Id("passwordNext"))), + ], + ), + ], + ), + Event::Trigger(Trigger::Query(By::ClassName( + "bootstrapperIframeContainerElement", + ))), + Event::Trigger(Trigger::SwitchFrame(By::Id("sM432dIframe"))), + Event::Assert(Assert::IsPresent("Gpay Tester")), + Event::Trigger(Trigger::Click(By::ClassName("jfk-button-action"))), + Event::Trigger(Trigger::SwitchTab(Position::Prev)), + ]; + self.complete_actions(&c, default_actions).await?; + self.complete_actions(&c, actions).await + } + async fn make_paypal_payment( + &self, + c: WebDriver, + url: &str, + actions: Vec>, + ) -> Result<(), WebDriverError> { + self.complete_actions( + &c, + vec![ + Event::Trigger(Trigger::Goto(url)), + Event::Trigger(Trigger::Click(By::Id("pypl-redirect-btn"))), + ], + ) + .await?; + let (email, pass) = ( + &get_env("PYPL_EMAIL").clone(), + &get_env("PYPL_PASS").clone(), + ); + let mut pypl_actions = vec![ + Event::EitherOr( + Assert::IsPresent("Password"), + vec![ + Event::Trigger(Trigger::SendKeys(By::Id("password"), pass)), + Event::Trigger(Trigger::Click(By::Id("btnLogin"))), + ], + vec![ + Event::Trigger(Trigger::SendKeys(By::Id("email"), email)), + Event::Trigger(Trigger::Click(By::Id("btnNext"))), + Event::Trigger(Trigger::SendKeys(By::Id("password"), pass)), + Event::Trigger(Trigger::Click(By::Id("btnLogin"))), + ], + ), + Event::Trigger(Trigger::Click(By::Id("payment-submit-btn"))), + ]; + pypl_actions.extend(actions); + self.complete_actions(&c, pypl_actions).await + } +} +async fn is_text_present(driver: &WebDriver, key: &str) -> WebDriverResult { + let mut xpath = "//*[contains(text(),'".to_owned(); + xpath.push_str(key); + xpath.push_str("')]"); + let result = driver.query(By::XPath(&xpath)).first().await?; + result.is_present().await +} +fn new_cookie(name: &str, value: String) -> Cookie<'_> { + let mut base_url_cookie = Cookie::new(name, value); + base_url_cookie.set_same_site(Some(SameSite::Lax)); + base_url_cookie.set_domain("hs-payment-tests.w3spaces.com"); + base_url_cookie.set_path("/"); + base_url_cookie +} + +#[macro_export] +macro_rules! tester_inner { + ($execute:ident, $webdriver:expr) => {{ + use std::{ + sync::{Arc, Mutex}, + thread, + }; + + let driver = $webdriver; + + // we'll need the session_id from the thread + // NOTE: even if it panics, so can't just return it + let session_id = Arc::new(Mutex::new(None)); + + // run test in its own thread to catch panics + let sid = session_id.clone(); + let res = thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let driver = runtime + .block_on(driver) + .expect("failed to construct test WebDriver"); + *sid.lock().unwrap() = runtime.block_on(driver.session_id()).ok(); + // make sure we close, even if an assertion fails + let client = driver.clone(); + let x = runtime.block_on(async move { + let r = tokio::spawn($execute(driver)).await; + let _ = client.quit().await; + r + }); + drop(runtime); + x.expect("test panicked") + }) + .join(); + let success = handle_test_error(res); + assert!(success); + }}; +} + +#[macro_export] +macro_rules! tester { + ($f:ident, $endpoint:expr) => {{ + use $crate::tester_inner; + + let url = make_url($endpoint); + let caps = make_capabilities($endpoint); + tester_inner!($f, WebDriver::new(url, caps)); + }}; +} + +pub fn make_capabilities(s: &str) -> Capabilities { + match s { + "firefox" => { + let mut caps = DesiredCapabilities::firefox(); + let profile_path = &format!("-profile={}", get_firefox_profile_path().unwrap()); + caps.add_firefox_arg(profile_path).unwrap(); + // let mut prefs = FirefoxPreferences::new(); + // prefs.set("-browser.link.open_newwindow", 3).unwrap(); + // caps.set_preferences(prefs).unwrap(); + caps.into() + } + "chrome" => { + let mut caps = DesiredCapabilities::chrome(); + let profile_path = &format!("user-data-dir={}", get_chrome_profile_path().unwrap()); + caps.add_chrome_arg(profile_path).unwrap(); + // caps.set_headless().unwrap(); + // caps.set_no_sandbox().unwrap(); + // caps.set_disable_gpu().unwrap(); + // caps.set_disable_dev_shm_usage().unwrap(); + caps.into() + } + &_ => DesiredCapabilities::safari().into(), + } +} +fn get_chrome_profile_path() -> Result { + env::var("CHROME_PROFILE_PATH").map_or_else( + //Issue: #924 + |_| -> Result { + let exe = env::current_exe()?; + let dir = exe.parent().expect("Executable must be in some directory"); + let mut base_path = dir + .to_str() + .map(|str| { + let mut fp = str.split(MAIN_SEPARATOR).collect::>(); + fp.truncate(3); + fp.join(&MAIN_SEPARATOR.to_string()) + }) + .unwrap(); + base_path.push_str(r#"/Library/Application\ Support/Google/Chrome/Default"#); + Ok(base_path) + }, + Ok, + ) +} +fn get_firefox_profile_path() -> Result { + env::var("FIREFOX_PROFILE_PATH").map_or_else( + //Issue: #924 + |_| -> Result { + let exe = env::current_exe()?; + let dir = exe.parent().expect("Executable must be in some directory"); + let mut base_path = dir + .to_str() + .map(|str| { + let mut fp = str.split(MAIN_SEPARATOR).collect::>(); + fp.truncate(3); + fp.join(&MAIN_SEPARATOR.to_string()) + }) + .unwrap(); + base_path.push_str(r#"/Library/Application Support/Firefox/Profiles/hs-test"#); + Ok(base_path) + }, + Ok, + ) +} + +pub fn make_url(s: &str) -> &'static str { + match s { + "firefox" => "http://localhost:4444", + "chrome" => "http://localhost:9515", + &_ => "", + } +} + +pub fn handle_test_error( + res: Result, Box>, +) -> bool { + match res { + Ok(Ok(_)) => true, + Ok(Err(e)) => { + eprintln!("test future failed to resolve: {:?}", e); + false + } + Err(e) => { + if let Some(e) = e.downcast_ref::() { + eprintln!("test future panicked: {:?}", e); + } else { + eprintln!("test future panicked; an assertion probably failed"); + } + false + } + } +} + +pub fn get_env(name: &str) -> String { + env::var(name).unwrap_or_else(|_| panic!("{name} not present")) //Issue: #924 +} diff --git a/crates/router/tests/connectors/utils.rs b/crates/router/tests/connectors/utils.rs index f803252e6d..6aa9742dee 100644 --- a/crates/router/tests/connectors/utils.rs +++ b/crates/router/tests/connectors/utils.rs @@ -400,6 +400,7 @@ pub trait ConnectorActions: Connector { Ok(types::PaymentsResponseData::SessionTokenResponse { .. }) => None, Ok(types::PaymentsResponseData::TokenizationResponse { .. }) => None, Ok(types::PaymentsResponseData::TransactionUnresolvedResponse { .. }) => None, + Ok(types::PaymentsResponseData::ThreeDSEnrollmentResponse { .. }) => None, Err(_) => None, } } @@ -581,6 +582,7 @@ pub fn get_connector_transaction_id( Ok(types::PaymentsResponseData::SessionTokenResponse { .. }) => None, Ok(types::PaymentsResponseData::TokenizationResponse { .. }) => None, Ok(types::PaymentsResponseData::TransactionUnresolvedResponse { .. }) => None, + Ok(types::PaymentsResponseData::ThreeDSEnrollmentResponse { .. }) => None, Err(_) => None, } }