From baf5fd91cf7fbb9f787e1ba137d1a3c597fe44ef Mon Sep 17 00:00:00 2001 From: Pa1NarK <69745008+pixincreate@users.noreply.github.com> Date: Thu, 11 May 2023 16:47:00 +0530 Subject: [PATCH] feat(connector): add connector nmi with card, applepay and googlepay support (#771) Signed-off-by: chikke srujan <121822803+srujanchikke@users.noreply.github.com> Co-authored-by: ShashiKant Co-authored-by: Arjun Karthik Co-authored-by: chikke srujan <121822803+srujanchikke@users.noreply.github.com> --- Cargo.lock | 11 + config/config.example.toml | 1 + config/development.toml | 2 + config/docker_compose.toml | 2 + crates/api_models/src/enums.rs | 2 + crates/common_utils/Cargo.toml | 1 + crates/common_utils/src/ext_traits.rs | 20 + crates/router/src/configs/settings.rs | 1 + crates/router/src/connector.rs | 6 +- crates/router/src/connector/nmi.rs | 590 +++++++++++++++ .../router/src/connector/nmi/transformers.rs | 672 +++++++++++++++++ crates/router/src/core/payments/flows.rs | 8 + crates/router/src/types.rs | 1 - crates/router/src/types/api.rs | 1 + .../router/tests/connectors/connector_auth.rs | 5 +- crates/router/tests/connectors/main.rs | 1 + crates/router/tests/connectors/nmi.rs | 692 ++++++++++++++++++ .../router/tests/connectors/sample_auth.toml | 3 + crates/router/tests/connectors/utils.rs | 8 +- loadtest/config/development.toml | 2 + 20 files changed, 2022 insertions(+), 7 deletions(-) create mode 100644 crates/router/src/connector/nmi.rs create mode 100644 crates/router/src/connector/nmi/transformers.rs create mode 100644 crates/router/tests/connectors/nmi.rs diff --git a/Cargo.lock b/Cargo.lock index 3ef85ac8f6..100e28daaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1318,6 +1318,7 @@ dependencies = [ "nanoid", "once_cell", "proptest", + "quick-xml", "rand 0.8.5", "regex", "ring", @@ -3368,6 +3369,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.26" diff --git a/config/config.example.toml b/config/config.example.toml index c0cf57015f..3138ae9dcf 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -166,6 +166,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" multisafepay.base_url = "https://testapi.multisafepay.com/" nexinets.base_url = "https://apitest.payengine.de/v1" +nmi.base_url = "https://secure.nmi.com/" nuvei.base_url = "https://ppp-test.nuvei.com/" opennode.base_url = "https://dev-api.opennode.com" payeezy.base_url = "https://api-cert.payeezy.com/" diff --git a/config/development.toml b/config/development.toml index 2aa9dc58d1..6929bdc045 100644 --- a/config/development.toml +++ b/config/development.toml @@ -72,6 +72,7 @@ cards = [ "mollie", "multisafepay", "nexinets", + "nmi", "nuvei", "opennode", "payeezy", @@ -121,6 +122,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" multisafepay.base_url = "https://testapi.multisafepay.com/" nexinets.base_url = "https://apitest.payengine.de/v1" +nmi.base_url = "https://secure.nmi.com/" nuvei.base_url = "https://ppp-test.nuvei.com/" opennode.base_url = "https://dev-api.opennode.com" payeezy.base_url = "https://api-cert.payeezy.com/" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 072dd4108a..b7e8a5261a 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -90,6 +90,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" multisafepay.base_url = "https://testapi.multisafepay.com/" nexinets.base_url = "https://apitest.payengine.de/v1" +nmi.base_url = "https://secure.nmi.com/" nuvei.base_url = "https://ppp-test.nuvei.com/" opennode.base_url = "https://dev-api.opennode.com" payeezy.base_url = "https://api-cert.payeezy.com/" @@ -129,6 +130,7 @@ cards = [ "mollie", "multisafepay", "nexinets", + "nmi", "nuvei", "opennode", "payeezy", diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index 0f5b109da5..e75693c95e 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -609,6 +609,7 @@ pub enum Connector { Mollie, Multisafepay, Nexinets, + Nmi, Nuvei, // Payeezy, As psync and rsync are not supported by this connector, it is added as template code for future usage Paypal, @@ -681,6 +682,7 @@ pub enum RoutableConnectors { Mollie, Multisafepay, Nexinets, + Nmi, Nuvei, Opennode, // Payeezy, As psync and rsync are not supported by this connector, it is added as template code for future usage diff --git a/crates/common_utils/Cargo.toml b/crates/common_utils/Cargo.toml index ae31147d5b..aa5ffab69e 100644 --- a/crates/common_utils/Cargo.toml +++ b/crates/common_utils/Cargo.toml @@ -20,6 +20,7 @@ futures = { version = "0.3.28", optional = true } hex = "0.4.3" nanoid = "0.4.0" once_cell = "1.17.1" +quick-xml = { version = "0.28.2", features = ["serialize"] } rand = "0.8.5" regex = "1.7.3" ring = "0.16.20" diff --git a/crates/common_utils/src/ext_traits.rs b/crates/common_utils/src/ext_traits.rs index ac626ff703..912d859c01 100644 --- a/crates/common_utils/src/ext_traits.rs +++ b/crates/common_utils/src/ext_traits.rs @@ -5,6 +5,7 @@ use error_stack::{IntoReport, ResultExt}; use masking::{ExposeInterface, Secret, Strategy}; +use quick_xml::de; use serde::{Deserialize, Serialize}; use crate::errors::{self, CustomResult}; @@ -392,3 +393,22 @@ impl ConfigExt for String { self.trim().is_empty() } } + +/// Extension trait for deserializing XML strings using `quick-xml` crate +pub trait XmlExt { + /// + /// Deserialize an XML string into the specified type ``. + /// + fn parse_xml(self) -> Result + where + T: serde::de::DeserializeOwned; +} + +impl XmlExt for &str { + fn parse_xml(self) -> Result + where + T: serde::de::DeserializeOwned, + { + de::from_str(self) + } +} diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 4e73119348..9f2bc3deba 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -376,6 +376,7 @@ pub struct Connectors { pub mollie: ConnectorParams, pub multisafepay: ConnectorParams, pub nexinets: ConnectorParams, + pub nmi: ConnectorParams, pub nuvei: ConnectorParams, pub opennode: ConnectorParams, pub payeezy: ConnectorParams, diff --git a/crates/router/src/connector.rs b/crates/router/src/connector.rs index 1b0298214e..c4143dddca 100644 --- a/crates/router/src/connector.rs +++ b/crates/router/src/connector.rs @@ -17,8 +17,10 @@ pub mod forte; pub mod globalpay; pub mod iatapay; pub mod klarna; +pub mod mollie; pub mod multisafepay; pub mod nexinets; +pub mod nmi; pub mod nuvei; pub mod opennode; pub mod payeezy; @@ -33,8 +35,6 @@ pub mod worldline; pub mod worldpay; pub mod zen; -pub mod mollie; - #[cfg(feature = "dummy_connector")] pub use self::dummyconnector::DummyConnector; pub use self::{ @@ -42,7 +42,7 @@ pub use self::{ bambora::Bambora, bitpay::Bitpay, bluesnap::Bluesnap, braintree::Braintree, checkout::Checkout, coinbase::Coinbase, cybersource::Cybersource, dlocal::Dlocal, fiserv::Fiserv, forte::Forte, globalpay::Globalpay, iatapay::Iatapay, klarna::Klarna, mollie::Mollie, - multisafepay::Multisafepay, nexinets::Nexinets, nuvei::Nuvei, opennode::Opennode, + multisafepay::Multisafepay, nexinets::Nexinets, nmi::Nmi, nuvei::Nuvei, opennode::Opennode, payeezy::Payeezy, paypal::Paypal, payu::Payu, rapyd::Rapyd, shift4::Shift4, stripe::Stripe, trustpay::Trustpay, worldline::Worldline, worldpay::Worldpay, zen::Zen, }; diff --git a/crates/router/src/connector/nmi.rs b/crates/router/src/connector/nmi.rs new file mode 100644 index 0000000000..be7be39500 --- /dev/null +++ b/crates/router/src/connector/nmi.rs @@ -0,0 +1,590 @@ +mod transformers; + +use std::fmt::Debug; + +use common_utils::ext_traits::ByteSliceExt; +use error_stack::{IntoReport, ResultExt}; +use transformers as nmi; + +use self::transformers::NmiCaptureRequest; +use crate::{ + configs::settings, + core::errors::{self, CustomResult}, + services::{self, ConnectorIntegration}, + types::{ + self, + api::{self, ConnectorCommon, ConnectorCommonExt}, + ErrorResponse, + }, + utils, +}; + +#[derive(Clone, Debug)] +pub struct Nmi; + +impl api::Payment for Nmi {} +impl api::PaymentSession for Nmi {} +impl api::ConnectorAccessToken for Nmi {} +impl api::PreVerify for Nmi {} +impl api::PaymentAuthorize for Nmi {} +impl api::PaymentSync for Nmi {} +impl api::PaymentCapture for Nmi {} +impl api::PaymentVoid for Nmi {} +impl api::Refund for Nmi {} +impl api::RefundExecute for Nmi {} +impl api::RefundSync for Nmi {} +impl api::PaymentToken for Nmi {} + +impl ConnectorCommonExt for Nmi +where + Self: ConnectorIntegration, +{ + fn build_headers( + &self, + _req: &types::RouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + )]) + } +} + +impl ConnectorCommon for Nmi { + fn id(&self) -> &'static str { + "nmi" + } + + fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str { + connectors.nmi.base_url.as_ref() + } + + fn build_error_response( + &self, + res: types::Response, + ) -> CustomResult { + let response: nmi::StandardResponse = res + .response + .parse_struct("StandardResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(ErrorResponse { + message: response.responsetext, + status_code: res.status_code, + reason: None, + ..Default::default() + }) + } +} + +impl + ConnectorIntegration< + api::PaymentMethodToken, + types::PaymentMethodTokenizationData, + types::PaymentsResponseData, + > for Nmi +{ +} + +impl ConnectorIntegration + for Nmi +{ +} + +impl ConnectorIntegration + for Nmi +{ +} + +impl ConnectorIntegration + for Nmi +{ + fn get_headers( + &self, + req: &types::VerifyRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_url( + &self, + _req: &types::VerifyRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/transact.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::VerifyRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_req = nmi::NmiPaymentsRequest::try_from(req)?; + let nmi_req = utils::Encode::::url_encode(&connector_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(nmi_req)) + } + + fn build_request( + &self, + req: &types::VerifyRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsVerifyType::get_url(self, req, connectors)?) + .headers(types::PaymentsVerifyType::get_headers( + self, req, connectors, + )?) + .body(types::PaymentsVerifyType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::VerifyRouterData, + res: types::Response, + ) -> CustomResult { + let response: nmi::StandardResponse = serde_urlencoded::from_bytes(&res.response) + .into_report() + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Nmi +{ + fn get_headers( + &self, + req: &types::PaymentsAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_url( + &self, + _req: &types::PaymentsAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/transact.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::PaymentsAuthorizeRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_req = nmi::NmiPaymentsRequest::try_from(req)?; + let nmi_req = utils::Encode::::url_encode(&connector_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(nmi_req)) + } + + fn build_request( + &self, + req: &types::PaymentsAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsAuthorizeType::get_url( + self, req, connectors, + )?) + .headers(types::PaymentsAuthorizeType::get_headers( + self, req, connectors, + )?) + .body(types::PaymentsAuthorizeType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsAuthorizeRouterData, + res: types::Response, + ) -> CustomResult { + let response: nmi::StandardResponse = serde_urlencoded::from_bytes(&res.response) + .into_report() + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Nmi +{ + fn get_headers( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_url( + &self, + _req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/query.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::PaymentsSyncRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_req = nmi::NmiSyncRequest::try_from(req)?; + let nmi_req = utils::Encode::::url_encode(&connector_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(nmi_req)) + } + + fn build_request( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsSyncType::get_url(self, req, connectors)?) + .headers(types::PaymentsSyncType::get_headers(self, req, connectors)?) + .body(types::PaymentsSyncType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsSyncRouterData, + res: types::Response, + ) -> CustomResult { + types::RouterData::try_from(types::ResponseRouterData { + response: res.clone(), + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Nmi +{ + fn get_headers( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_url( + &self, + _req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/transact.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::PaymentsCaptureRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_req = nmi::NmiCaptureRequest::try_from(req)?; + let nmi_req = utils::Encode::::url_encode(&connector_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(nmi_req)) + } + + fn build_request( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsCaptureType::get_url(self, req, connectors)?) + .headers(types::PaymentsCaptureType::get_headers( + self, req, connectors, + )?) + .body(types::PaymentsCaptureType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsCaptureRouterData, + res: types::Response, + ) -> CustomResult { + let response: nmi::StandardResponse = serde_urlencoded::from_bytes(&res.response) + .into_report() + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Nmi +{ + fn get_headers( + &self, + req: &types::PaymentsCancelRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_url( + &self, + _req: &types::PaymentsCancelRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/transact.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::PaymentsCancelRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_req = nmi::NmiCancelRequest::try_from(req)?; + let nmi_req = utils::Encode::::url_encode(&connector_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(nmi_req)) + } + + fn build_request( + &self, + req: &types::PaymentsCancelRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsVoidType::get_url(self, req, connectors)?) + .headers(types::PaymentsVoidType::get_headers(self, req, connectors)?) + .body(types::PaymentsVoidType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsCancelRouterData, + res: types::Response, + ) -> CustomResult { + let response: nmi::StandardResponse = serde_urlencoded::from_bytes(&res.response) + .into_report() + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration for Nmi { + fn get_headers( + &self, + req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_url( + &self, + _req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/transact.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::RefundsRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_req = nmi::NmiRefundRequest::try_from(req)?; + let nmi_req = utils::Encode::::url_encode(&connector_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(nmi_req)) + } + + fn build_request( + &self, + req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::RefundExecuteType::get_url(self, req, connectors)?) + .headers(types::RefundExecuteType::get_headers( + self, req, connectors, + )?) + .body(types::RefundExecuteType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::RefundsRouterData, + res: types::Response, + ) -> CustomResult, errors::ConnectorError> { + let response: nmi::StandardResponse = serde_urlencoded::from_bytes(&res.response) + .into_report() + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration for Nmi { + fn get_headers( + &self, + req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_url( + &self, + _req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/query.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::RefundsRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_req = nmi::NmiSyncRequest::try_from(req)?; + let nmi_req = utils::Encode::::url_encode(&connector_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(nmi_req)) + } + + fn build_request( + &self, + req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::RefundSyncType::get_url(self, req, connectors)?) + .headers(types::RefundSyncType::get_headers(self, req, connectors)?) + .body(types::RefundSyncType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::RefundsRouterData, + res: types::Response, + ) -> CustomResult, errors::ConnectorError> { + types::RouterData::try_from(types::ResponseRouterData { + response: res.clone(), + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +#[async_trait::async_trait] +impl api::IncomingWebhook for Nmi { + fn get_webhook_object_reference_id( + &self, + _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } + + fn get_webhook_event_type( + &self, + _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } + + fn get_webhook_resource_object( + &self, + _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } +} diff --git a/crates/router/src/connector/nmi/transformers.rs b/crates/router/src/connector/nmi/transformers.rs new file mode 100644 index 0000000000..eb09497ced --- /dev/null +++ b/crates/router/src/connector/nmi/transformers.rs @@ -0,0 +1,672 @@ +use cards::CardNumber; +use common_utils::ext_traits::XmlExt; +use error_stack::{IntoReport, Report, ResultExt}; +use masking::Secret; +use serde::{Deserialize, Serialize}; + +use crate::{ + connector::utils::{self, PaymentsAuthorizeRequestData}, + core::errors, + types::{self, api, storage::enums, transformers::ForeignFrom, ConnectorAuthType}, +}; + +type Error = Report; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TransactionType { + Auth, + Capture, + Refund, + Sale, + Validate, + Void, +} + +pub struct NmiAuthType { + pub(super) api_key: String, +} + +impl TryFrom<&ConnectorAuthType> for NmiAuthType { + type Error = Error; + fn try_from(auth_type: &ConnectorAuthType) -> Result { + if let types::ConnectorAuthType::HeaderKey { api_key } = auth_type { + Ok(Self { + api_key: api_key.to_string(), + }) + } else { + Err(errors::ConnectorError::FailedToObtainAuthType.into()) + } + } +} + +#[derive(Debug, Serialize)] +pub struct NmiPaymentsRequest { + #[serde(rename = "type")] + transaction_type: TransactionType, + amount: f64, + security_key: Secret, + currency: enums::Currency, + #[serde(flatten)] + payment_method: PaymentMethod, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum PaymentMethod { + Card(Box), + GPay(Box), + ApplePay(Box), +} + +#[derive(Debug, Serialize)] +pub struct CardData { + ccnumber: CardNumber, + ccexp: Secret, + cvv: Secret, +} + +#[derive(Debug, Serialize)] +pub struct GooglePayData { + googlepay_payment_data: String, +} + +#[derive(Debug, Serialize)] +pub struct ApplePayData { + applepay_payment_data: String, +} + +impl TryFrom<&types::PaymentsAuthorizeRouterData> for NmiPaymentsRequest { + type Error = Error; + fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result { + let transaction_type = match item.request.is_auto_capture()? { + true => TransactionType::Sale, + false => TransactionType::Auth, + }; + let auth_type: NmiAuthType = (&item.connector_auth_type).try_into()?; + let amount = + utils::to_currency_base_unit_asf64(item.request.amount, item.request.currency)?; + let payment_method = PaymentMethod::try_from(&item.request.payment_method_data)?; + + Ok(Self { + transaction_type, + security_key: auth_type.api_key.into(), + amount, + currency: item.request.currency, + payment_method, + }) + } +} + +impl TryFrom<&api_models::payments::PaymentMethodData> for PaymentMethod { + type Error = Error; + fn try_from( + payment_method_data: &api_models::payments::PaymentMethodData, + ) -> Result { + match &payment_method_data { + api::PaymentMethodData::Card(ref card) => Ok(Self::from(card)), + api::PaymentMethodData::Wallet(ref wallet_type) => match wallet_type { + api_models::payments::WalletData::GooglePay(ref googlepay_data) => { + Ok(Self::from(googlepay_data)) + } + api_models::payments::WalletData::ApplePay(ref applepay_data) => { + Ok(Self::from(applepay_data)) + } + _ => Err(errors::ConnectorError::NotImplemented( + "Payment Method".to_string(), + )) + .into_report(), + }, + _ => Err(errors::ConnectorError::NotImplemented( + "Payment Method".to_string(), + )) + .into_report(), + } + } +} + +impl From<&api_models::payments::Card> for PaymentMethod { + fn from(card: &api_models::payments::Card) -> Self { + let ccexp = utils::CardData::get_card_expiry_month_year_2_digit_with_delimiter( + card, + "".to_string(), + ); + let card = CardData { + ccnumber: card.card_number.clone(), + ccexp, + cvv: card.card_cvc.clone(), + }; + Self::Card(Box::new(card)) + } +} + +impl From<&api_models::payments::GooglePayWalletData> for PaymentMethod { + fn from(wallet_data: &api_models::payments::GooglePayWalletData) -> Self { + let gpay_data = GooglePayData { + googlepay_payment_data: wallet_data.tokenization_data.token.clone(), + }; + Self::GPay(Box::new(gpay_data)) + } +} + +impl From<&api_models::payments::ApplePayWalletData> for PaymentMethod { + fn from(wallet_data: &api_models::payments::ApplePayWalletData) -> Self { + let apple_pay_data = ApplePayData { + applepay_payment_data: wallet_data.payment_data.clone(), + }; + Self::ApplePay(Box::new(apple_pay_data)) + } +} + +impl TryFrom<&types::VerifyRouterData> for NmiPaymentsRequest { + type Error = Error; + fn try_from(item: &types::VerifyRouterData) -> Result { + let auth_type: NmiAuthType = (&item.connector_auth_type).try_into()?; + let payment_method = PaymentMethod::try_from(&item.request.payment_method_data)?; + Ok(Self { + transaction_type: TransactionType::Validate, + security_key: auth_type.api_key.into(), + amount: 0.0, + currency: item.request.currency, + payment_method, + }) + } +} + +#[derive(Debug, Serialize)] +pub struct NmiSyncRequest { + pub transaction_id: String, + pub security_key: String, +} + +impl TryFrom<&types::PaymentsSyncRouterData> for NmiSyncRequest { + type Error = Error; + fn try_from(item: &types::PaymentsSyncRouterData) -> Result { + let auth = NmiAuthType::try_from(&item.connector_auth_type)?; + Ok(Self { + security_key: auth.api_key, + transaction_id: item + .request + .connector_transaction_id + .get_connector_transaction_id() + .change_context(errors::ConnectorError::MissingConnectorTransactionID)?, + }) + } +} + +#[derive(Debug, Serialize)] +pub struct NmiCaptureRequest { + #[serde(rename = "type")] + pub transaction_type: TransactionType, + pub security_key: Secret, + pub transactionid: String, + pub amount: Option, +} + +impl TryFrom<&types::PaymentsCaptureRouterData> for NmiCaptureRequest { + type Error = Error; + fn try_from(item: &types::PaymentsCaptureRouterData) -> Result { + let auth = NmiAuthType::try_from(&item.connector_auth_type)?; + Ok(Self { + transaction_type: TransactionType::Capture, + security_key: auth.api_key.into(), + transactionid: item.request.connector_transaction_id.clone(), + amount: Some(utils::to_currency_base_unit_asf64( + item.request.amount_to_capture, + item.request.currency, + )?), + }) + } +} + +impl + TryFrom< + types::ResponseRouterData< + api::Capture, + StandardResponse, + types::PaymentsCaptureData, + types::PaymentsResponseData, + >, + > for types::RouterData +{ + type Error = Error; + fn try_from( + item: types::ResponseRouterData< + api::Capture, + StandardResponse, + types::PaymentsCaptureData, + types::PaymentsResponseData, + >, + ) -> Result { + let (response, status) = match item.response.response { + Response::Approved => ( + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId( + item.response.transactionid, + ), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + }), + enums::AttemptStatus::CaptureInitiated, + ), + Response::Declined | Response::Error => ( + Err(types::ErrorResponse::foreign_from(( + item.response, + item.http_code, + ))), + enums::AttemptStatus::CaptureFailed, + ), + }; + Ok(Self { + status, + response, + ..item.data + }) + } +} + +#[derive(Debug, Serialize)] +pub struct NmiCancelRequest { + #[serde(rename = "type")] + pub transaction_type: TransactionType, + pub security_key: Secret, + pub transactionid: String, + pub void_reason: Option, +} + +impl TryFrom<&types::PaymentsCancelRouterData> for NmiCancelRequest { + type Error = Error; + fn try_from(item: &types::PaymentsCancelRouterData) -> Result { + let auth = NmiAuthType::try_from(&item.connector_auth_type)?; + Ok(Self { + transaction_type: TransactionType::Void, + security_key: auth.api_key.into(), + transactionid: item.request.connector_transaction_id.clone(), + void_reason: item.request.cancellation_reason.clone(), + }) + } +} + +#[derive(Debug, Deserialize)] +pub enum Response { + #[serde(alias = "1")] + Approved, + #[serde(alias = "2")] + Declined, + #[serde(alias = "3")] + Error, +} + +#[derive(Debug, Deserialize)] +pub struct StandardResponse { + pub response: Response, + pub responsetext: String, + pub authcode: Option, + pub transactionid: String, + pub avsresponse: Option, + pub cvvresponse: Option, + pub orderid: String, + pub response_code: String, +} + +impl + TryFrom< + types::ResponseRouterData, + > for types::RouterData +{ + type Error = Error; + fn try_from( + item: types::ResponseRouterData< + api::Verify, + StandardResponse, + T, + types::PaymentsResponseData, + >, + ) -> Result { + let (response, status) = match item.response.response { + Response::Approved => ( + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId( + item.response.transactionid, + ), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + }), + enums::AttemptStatus::Charged, + ), + Response::Declined | Response::Error => ( + Err(types::ErrorResponse::foreign_from(( + item.response, + item.http_code, + ))), + enums::AttemptStatus::Failure, + ), + }; + Ok(Self { + status, + response, + ..item.data + }) + } +} + +impl ForeignFrom<(StandardResponse, u16)> for types::ErrorResponse { + fn foreign_from((response, http_code): (StandardResponse, u16)) -> Self { + Self { + code: response.response_code, + message: response.responsetext, + reason: None, + status_code: http_code, + } + } +} + +impl TryFrom> + for types::RouterData +{ + type Error = Error; + fn try_from( + item: types::ResponseRouterData< + api::Authorize, + StandardResponse, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + >, + ) -> Result { + let (response, status) = match item.response.response { + Response::Approved => ( + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId( + item.response.transactionid, + ), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + }), + if let Some(storage_models::enums::CaptureMethod::Automatic) = + item.data.request.capture_method + { + enums::AttemptStatus::CaptureInitiated + } else { + enums::AttemptStatus::Authorizing + }, + ), + Response::Declined | Response::Error => ( + Err(types::ErrorResponse::foreign_from(( + item.response, + item.http_code, + ))), + enums::AttemptStatus::Failure, + ), + }; + Ok(Self { + status, + response, + ..item.data + }) + } +} + +impl + TryFrom> + for types::RouterData +{ + type Error = Error; + fn try_from( + item: types::ResponseRouterData< + api::Void, + StandardResponse, + T, + types::PaymentsResponseData, + >, + ) -> Result { + let (response, status) = match item.response.response { + Response::Approved => ( + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId( + item.response.transactionid, + ), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + }), + enums::AttemptStatus::VoidInitiated, + ), + Response::Declined | Response::Error => ( + Err(types::ErrorResponse::foreign_from(( + item.response, + item.http_code, + ))), + enums::AttemptStatus::VoidFailed, + ), + }; + Ok(Self { + status, + response, + ..item.data + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NmiStatus { + Abandoned, + Cancelled, + Pendingsettlement, + Pending, + Failed, + Complete, + InProgress, + Unknown, +} + +impl TryFrom> + for types::PaymentsSyncRouterData +{ + type Error = Error; + fn try_from( + item: types::PaymentsSyncResponseRouterData, + ) -> Result { + let response = SyncResponse::try_from(item.response.response.to_vec())?; + Ok(Self { + status: enums::AttemptStatus::from(NmiStatus::from(response.transaction.condition)), + response: Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId( + response.transaction.transaction_id, + ), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + }), + ..item.data + }) + } +} + +impl TryFrom> for SyncResponse { + type Error = Error; + fn try_from(bytes: Vec) -> Result { + let query_response = String::from_utf8(bytes) + .into_report() + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + query_response + .parse_xml::() + .into_report() + .change_context(errors::ConnectorError::ResponseDeserializationFailed) + } +} + +impl From for enums::AttemptStatus { + fn from(item: NmiStatus) -> Self { + match item { + NmiStatus::Abandoned => Self::AuthenticationFailed, + NmiStatus::Cancelled => Self::Voided, + NmiStatus::Pending => Self::Authorized, + NmiStatus::Pendingsettlement => Self::Pending, + NmiStatus::Complete => Self::Charged, + NmiStatus::InProgress => Self::AuthenticationPending, + NmiStatus::Failed | NmiStatus::Unknown => Self::Failure, + } + } +} + +// REFUND : +#[derive(Debug, Serialize)] +pub struct NmiRefundRequest { + #[serde(rename = "type")] + transaction_type: TransactionType, + security_key: String, + transactionid: String, + amount: f64, +} + +impl TryFrom<&types::RefundsRouterData> for NmiRefundRequest { + type Error = Error; + fn try_from(item: &types::RefundsRouterData) -> Result { + let auth_type: NmiAuthType = (&item.connector_auth_type).try_into()?; + Ok(Self { + transaction_type: TransactionType::Refund, + security_key: auth_type.api_key, + transactionid: item.request.connector_transaction_id.clone(), + amount: utils::to_currency_base_unit_asf64( + item.request.refund_amount, + item.request.currency, + )?, + }) + } +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = Error; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + let refund_status = enums::RefundStatus::from(item.response.response); + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id: item.response.transactionid, + refund_status, + }), + ..item.data + }) + } +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = Error; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + let refund_status = enums::RefundStatus::from(item.response.response); + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id: item.response.transactionid, + refund_status, + }), + ..item.data + }) + } +} + +impl From for enums::RefundStatus { + fn from(item: Response) -> Self { + match item { + Response::Approved => Self::Pending, + Response::Declined | Response::Error => Self::Failure, + } + } +} + +impl TryFrom<&types::RefundSyncRouterData> for NmiSyncRequest { + type Error = Error; + fn try_from(item: &types::RefundSyncRouterData) -> Result { + let auth = NmiAuthType::try_from(&item.connector_auth_type)?; + let transaction_id = item + .request + .connector_refund_id + .clone() + .ok_or(errors::ConnectorError::MissingConnectorRefundID)?; + + Ok(Self { + security_key: auth.api_key, + transaction_id, + }) + } +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = Error; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + let response = SyncResponse::try_from(item.response.response.to_vec())?; + let refund_status = + enums::RefundStatus::from(NmiStatus::from(response.transaction.condition)); + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id: response.transaction.transaction_id, + refund_status, + }), + ..item.data + }) + } +} + +impl From for enums::RefundStatus { + fn from(item: NmiStatus) -> Self { + match item { + NmiStatus::Abandoned + | NmiStatus::Cancelled + | NmiStatus::Failed + | NmiStatus::Unknown => Self::Failure, + NmiStatus::Pendingsettlement | NmiStatus::Pending | NmiStatus::InProgress => { + Self::Pending + } + NmiStatus::Complete => Self::Success, + } + } +} + +impl From for NmiStatus { + fn from(value: String) -> Self { + match value.as_str() { + "abandoned" => Self::Abandoned, + "canceled" => Self::Cancelled, + "in_progress" => Self::InProgress, + "pendingsettlement" => Self::Pendingsettlement, + "complete" => Self::Complete, + "failed" => Self::Failed, + "unknown" => Self::Unknown, + // Other than above values only pending is possible, since value is a string handling this as default + _ => Self::Pending, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct SyncTransactionResponse { + #[serde(rename = "transaction_id")] + transaction_id: String, + #[serde(rename = "condition")] + condition: String, +} + +#[derive(Debug, Deserialize)] +struct SyncResponse { + #[serde(rename = "transaction")] + transaction: SyncTransactionResponse, +} diff --git a/crates/router/src/core/payments/flows.rs b/crates/router/src/core/payments/flows.rs index 6e8fe22685..0a1be35c6c 100644 --- a/crates/router/src/core/payments/flows.rs +++ b/crates/router/src/core/payments/flows.rs @@ -119,6 +119,7 @@ default_imp_for_complete_authorize!( connector::Klarna, connector::Multisafepay, connector::Nexinets, + connector::Nmi, connector::Opennode, connector::Payeezy, connector::Payu, @@ -168,6 +169,7 @@ default_imp_for_create_customer!( connector::Mollie, connector::Multisafepay, connector::Nexinets, + connector::Nmi, connector::Nuvei, connector::Opennode, connector::Payeezy, @@ -216,6 +218,7 @@ default_imp_for_connector_redirect_response!( connector::Klarna, connector::Multisafepay, connector::Nexinets, + connector::Nmi, connector::Opennode, connector::Payeezy, connector::Payu, @@ -256,6 +259,7 @@ default_imp_for_connector_request_id!( connector::Klarna, connector::Mollie, connector::Multisafepay, + connector::Nmi, connector::Nuvei, connector::Opennode, connector::Payeezy, @@ -308,6 +312,7 @@ default_imp_for_accept_dispute!( connector::Mollie, connector::Multisafepay, connector::Nexinets, + connector::Nmi, connector::Nuvei, connector::Payeezy, connector::Paypal, @@ -369,6 +374,7 @@ default_imp_for_file_upload!( connector::Mollie, connector::Multisafepay, connector::Nexinets, + connector::Nmi, connector::Nuvei, connector::Payeezy, connector::Paypal, @@ -420,6 +426,7 @@ default_imp_for_submit_evidence!( connector::Mollie, connector::Multisafepay, connector::Nexinets, + connector::Nmi, connector::Nuvei, connector::Payeezy, connector::Paypal, @@ -471,6 +478,7 @@ default_imp_for_defend_dispute!( connector::Mollie, connector::Multisafepay, connector::Nexinets, + connector::Nmi, connector::Nuvei, connector::Payeezy, connector::Paypal, diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index 93843f326f..c462b55912 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -100,7 +100,6 @@ pub type PaymentsSessionType = dyn services::ConnectorIntegration; pub type PaymentsVoidType = dyn services::ConnectorIntegration; - pub type TokenizationType = dyn services::ConnectorIntegration< api::PaymentMethodToken, PaymentMethodTokenizationData, diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index d4d7807273..4b4b2f803e 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -218,6 +218,7 @@ impl ConnectorData { "iatapay" => Ok(Box::new(&connector::Iatapay)), "klarna" => Ok(Box::new(&connector::Klarna)), "mollie" => Ok(Box::new(&connector::Mollie)), + "nmi" => Ok(Box::new(&connector::Nmi)), "nuvei" => Ok(Box::new(&connector::Nuvei)), "opennode" => Ok(Box::new(&connector::Opennode)), // "payeezy" => Ok(Box::new(&connector::Payeezy)), As psync and rsync are not supported by this connector, it is added as template code for future usage diff --git a/crates/router/tests/connectors/connector_auth.rs b/crates/router/tests/connectors/connector_auth.rs index e42b579b54..7cc2c53841 100644 --- a/crates/router/tests/connectors/connector_auth.rs +++ b/crates/router/tests/connectors/connector_auth.rs @@ -24,7 +24,8 @@ pub(crate) struct ConnectorAuthentication { pub iatapay: Option, pub mollie: Option, pub multisafepay: Option, - pub nexinets: Option, + pub nexinets: Option, + pub nmi: Option, pub nuvei: Option, pub opennode: Option, pub payeezy: Option, @@ -42,6 +43,8 @@ pub(crate) struct ConnectorAuthentication { impl ConnectorAuthentication { #[allow(clippy::expect_used)] pub(crate) fn new() -> Self { + // Do `export CONNECTOR_AUTH_FILE_PATH="/hyperswitch/crates/router/tests/connectors/sample_auth.toml"` + // before running tests let path = env::var("CONNECTOR_AUTH_FILE_PATH") .expect("connector authentication file path not set"); toml::from_str( diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index ba31db4cb1..563a872a8f 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -27,6 +27,7 @@ mod iatapay; mod mollie; mod multisafepay; mod nexinets; +mod nmi; mod nuvei; mod nuvei_ui; mod opennode; diff --git a/crates/router/tests/connectors/nmi.rs b/crates/router/tests/connectors/nmi.rs new file mode 100644 index 0000000000..0727ef8da3 --- /dev/null +++ b/crates/router/tests/connectors/nmi.rs @@ -0,0 +1,692 @@ +use std::{str::FromStr, time::Duration}; + +use router::types::{self, api, storage::enums}; + +use crate::{ + connector_auth, + utils::{self, ConnectorActions}, +}; + +struct NmiTest; +impl ConnectorActions for NmiTest {} +impl utils::Connector for NmiTest { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::Nmi; + types::api::ConnectorData { + connector: Box::new(&Nmi), + connector_name: types::Connector::Nmi, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + connector_auth::ConnectorAuthentication::new() + .nmi + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + "nmi".to_string() + } +} + +static CONNECTOR: NmiTest = NmiTest {}; + +fn get_payment_authorize_data() -> Option { + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethodData::Card(api::Card { + card_number: cards::CardNumber::from_str("4111111111111111").unwrap(), + ..utils::CCardType::default().0 + }), + amount: 2023, + ..utils::PaymentAuthorizeType::default().0 + }) +} + +// Cards Positive Tests +// Creates a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_only_authorize_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), None) + .await + .expect("Authorize payment response"); + let transaction_id = utils::get_connector_transaction_id(response.response).unwrap(); + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + // Assert the sync response, it will be authorized in case of manual capture, for automatic it will be Completed Success + assert_eq!(sync_response.status, enums::AttemptStatus::Authorized); +} + +// Captures a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_capture_authorized_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Authorized); + let capture_response = CONNECTOR + .capture_payment(transaction_id.clone(), None, None) + .await + .unwrap(); + assert_eq!( + capture_response.status, + enums::AttemptStatus::CaptureInitiated + ); + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); +} + +// Partially captures a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_partially_capture_authorized_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Authorized); + let capture_response = CONNECTOR + .capture_payment( + transaction_id.clone(), + Some(types::PaymentsCaptureData { + amount_to_capture: 1000, + ..utils::PaymentCaptureType::default().0 + }), + None, + ) + .await + .unwrap(); + assert_eq!( + capture_response.status, + enums::AttemptStatus::CaptureInitiated + ); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); +} + +// Voids a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_void_authorized_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Authorized); + + let void_response = CONNECTOR + .void_payment( + transaction_id.clone(), + Some(types::PaymentsCancelData { + connector_transaction_id: String::from(""), + cancellation_reason: Some("user_cancel".to_string()), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(void_response.status, enums::AttemptStatus::VoidInitiated); + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Voided, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Voided); +} + +// Refunds a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_refund_manually_captured_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Authorized); + let capture_response = CONNECTOR + .capture_payment(transaction_id.clone(), None, None) + .await + .unwrap(); + assert_eq!( + capture_response.status, + enums::AttemptStatus::CaptureInitiated + ); + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); + + let refund_response = CONNECTOR + .refund_payment(transaction_id.clone(), None, None) + .await + .unwrap(); + assert_eq!(refund_response.status, enums::AttemptStatus::Pending); + let sync_response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Pending, + refund_response.response.unwrap().connector_refund_id, + None, + None, + ) + .await + .unwrap(); + assert_eq!( + sync_response.response.unwrap().refund_status, + enums::RefundStatus::Pending + ); +} + +// Partially refunds a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_partially_refund_manually_captured_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Authorized); + let capture_response = CONNECTOR + .capture_payment( + transaction_id.clone(), + Some(types::PaymentsCaptureData { + amount_to_capture: 2023, + ..utils::PaymentCaptureType::default().0 + }), + None, + ) + .await + .unwrap(); + assert_eq!( + capture_response.status, + enums::AttemptStatus::CaptureInitiated + ); + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); + + let refund_response = CONNECTOR + .refund_payment( + transaction_id.clone(), + Some(types::RefundsData { + refund_amount: 1023, + ..utils::PaymentRefundType::default().0 + }), + None, + ) + .await + .unwrap(); + assert_eq!(refund_response.status, enums::AttemptStatus::Pending); + let sync_response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Pending, + refund_response.response.unwrap().connector_refund_id, + None, + None, + ) + .await + .unwrap(); + assert_eq!( + sync_response.response.unwrap().refund_status, + enums::RefundStatus::Pending + ); +} + +// Creates a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_make_payment() { + let response = CONNECTOR + .make_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Automatic), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); +} + +// Refunds a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_refund_auto_captured_payment() { + let response = CONNECTOR + .make_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Automatic), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); + + let refund_response = CONNECTOR + .refund_payment(transaction_id.clone(), None, None) + .await + .unwrap(); + assert_eq!(refund_response.status, enums::AttemptStatus::Pending); + let sync_response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Pending, + refund_response.response.unwrap().connector_refund_id, + None, + None, + ) + .await + .unwrap(); + assert_eq!( + sync_response.response.unwrap().refund_status, + enums::RefundStatus::Pending + ); +} + +// Partially refunds a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_partially_refund_succeeded_payment() { + let response = CONNECTOR + .make_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Automatic), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); + + let refund_response = CONNECTOR + .refund_payment( + transaction_id.clone(), + Some(types::RefundsData { + refund_amount: 1000, + ..utils::PaymentRefundType::default().0 + }), + None, + ) + .await + .unwrap(); + assert_eq!(refund_response.status, enums::AttemptStatus::Pending); + let sync_response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Pending, + refund_response.response.unwrap().connector_refund_id, + None, + None, + ) + .await + .unwrap(); + assert_eq!( + sync_response.response.unwrap().refund_status, + enums::RefundStatus::Pending + ); +} + +// Creates multiple refunds against a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_refund_succeeded_payment_multiple_times() { + let response = CONNECTOR + .make_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Automatic), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); + + //try refund for previous payment + let transaction_id = utils::get_connector_transaction_id(response.response).unwrap(); + for _x in 0..2 { + tokio::time::sleep(Duration::from_secs(5)).await; // to avoid 404 error + let refund_response = CONNECTOR + .refund_payment( + transaction_id.clone(), + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + None, + ) + .await + .unwrap(); + let sync_response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Pending, + refund_response.response.unwrap().connector_refund_id, + None, + None, + ) + .await + .unwrap(); + assert_eq!( + sync_response.response.unwrap().refund_status, + enums::RefundStatus::Pending, + ); + } +} + +// Creates a payment with incorrect CVC. +#[ignore = "Connector returns SUCCESS status in case of invalid CVC"] +#[actix_web::test] +async fn should_fail_payment_for_incorrect_cvc() {} + +// Creates a payment with incorrect expiry month. +#[ignore = "Connector returns SUCCESS status in case of expired month."] +#[actix_web::test] +async fn should_fail_payment_for_invalid_exp_month() {} + +// Creates a payment with incorrect expiry year. +#[ignore = "Connector returns SUCCESS status in case of expired year."] +#[actix_web::test] +async fn should_fail_payment_for_incorrect_expiry_year() {} + +// Voids a payment using automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_fail_void_payment_for_auto_capture() { + let response = CONNECTOR + .make_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Automatic), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); + + let void_response = CONNECTOR + .void_payment(transaction_id.clone(), None, None) + .await + .unwrap(); + assert_eq!(void_response.status, enums::AttemptStatus::VoidFailed); +} + +// Captures a payment using invalid connector payment id. +#[actix_web::test] +async fn should_fail_capture_for_invalid_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Manual), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Authorized); + let capture_response = CONNECTOR + .capture_payment("7899353591".to_string(), None, None) + .await + .unwrap(); + assert_eq!(capture_response.status, enums::AttemptStatus::CaptureFailed); +} + +// Refunds a payment with refund amount higher than payment amount. +#[actix_web::test] +async fn should_fail_for_refund_amount_higher_than_payment_amount() { + let response = CONNECTOR + .make_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let transaction_id = utils::get_connector_transaction_id(response.response.to_owned()).unwrap(); + + let sync_response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Pending, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + transaction_id.clone(), + ), + capture_method: Some(types::storage::enums::CaptureMethod::Automatic), + ..Default::default() + }), + None, + ) + .await + .unwrap(); + assert_eq!(sync_response.status, enums::AttemptStatus::Pending); + let refund_response = CONNECTOR + .refund_payment( + transaction_id, + Some(types::RefundsData { + refund_amount: 3024, + ..utils::PaymentRefundType::default().0 + }), + None, + ) + .await + .unwrap(); + assert_eq!( + refund_response.response.unwrap().refund_status, + enums::RefundStatus::Failure + ); +} diff --git a/crates/router/tests/connectors/sample_auth.toml b/crates/router/tests/connectors/sample_auth.toml index 0448e782eb..146cfeed62 100644 --- a/crates/router/tests/connectors/sample_auth.toml +++ b/crates/router/tests/connectors/sample_auth.toml @@ -63,6 +63,9 @@ api_secret = "secret" api_key = "api_key" key1= "key1" +[nmi] +api_key = "NMI API Key" + [nuvei] api_key = "api_key" key1 = "key1" diff --git a/crates/router/tests/connectors/utils.rs b/crates/router/tests/connectors/utils.rs index fe9c7163ad..a45038047b 100644 --- a/crates/router/tests/connectors/utils.rs +++ b/crates/router/tests/connectors/utils.rs @@ -37,6 +37,8 @@ pub struct PaymentInfo { #[async_trait] pub trait ConnectorActions: Connector { + /// For initiating payments when `CaptureMethod` is set to `Manual` + /// This doesn't complete the transaction, `PaymentsCapture` needs to be done manually async fn authorize_payment( &self, payment_data: Option, @@ -62,6 +64,8 @@ pub trait ConnectorActions: Connector { call_connector(request, integration).await } + /// For initiating payments when `CaptureMethod` is set to `Automatic` + /// This does complete the transaction without user intervention to Capture the payment async fn make_payment( &self, payment_data: Option, @@ -197,14 +201,14 @@ pub trait ConnectorActions: Connector { async fn refund_payment( &self, transaction_id: String, - payment_data: Option, + refund_data: Option, payment_info: Option, ) -> Result> { let integration = self.get_data().connector.get_connector_integration(); let request = self.generate_data( types::RefundsData { connector_transaction_id: transaction_id, - ..payment_data.unwrap_or(PaymentRefundType::default().0) + ..refund_data.unwrap_or(PaymentRefundType::default().0) }, payment_info, ); diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index 5c5653fdc7..70b88c322d 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -79,6 +79,7 @@ klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" multisafepay.base_url = "https://testapi.multisafepay.com/" nexinets.base_url = "https://apitest.payengine.de/v1" +nmi.base_url = "https://secure.nmi.com/" nuvei.base_url = "https://ppp-test.nuvei.com/" opennode.base_url = "https://dev-api.opennode.com" payeezy.base_url = "https://api-cert.payeezy.com/" @@ -117,6 +118,7 @@ cards = [ "mollie", "multisafepay", "nexinets", + "nmi", "nuvei", "opennode", "payeezy",