From 679c3bfbbfe2cf5afb935bea00b427f1b656544f Mon Sep 17 00:00:00 2001 From: Sk Sakil Mostak Date: Fri, 15 Dec 2023 18:44:41 +0530 Subject: [PATCH 1/3] feat: add 3ds for cards --- crates/router/src/connector/nmi.rs | 169 ++++++++++ .../router/src/connector/nmi/transformers.rs | 308 +++++++++++++++++- crates/router/src/core/payments.rs | 7 + crates/router/src/core/payments/flows.rs | 2 - crates/router/src/services/api.rs | 82 ++++- 5 files changed, 557 insertions(+), 11 deletions(-) diff --git a/crates/router/src/connector/nmi.rs b/crates/router/src/connector/nmi.rs index 83a62b13010..e7e92955213 100644 --- a/crates/router/src/connector/nmi.rs +++ b/crates/router/src/connector/nmi.rs @@ -187,6 +187,90 @@ impl } } +impl api::PaymentsPreProcessing for Nmi {} + +impl + ConnectorIntegration< + api::PreProcessing, + types::PaymentsPreProcessingData, + types::PaymentsResponseData, + > for Nmi +{ + fn get_headers( + &self, + req: &types::PaymentsPreProcessingRouterData, + connectors: &settings::Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + _req: &types::PaymentsPreProcessingRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/transact.php", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::PaymentsPreProcessingRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult { + let connector_req = nmi::NmiVaultRequest::try_from(req)?; + Ok(RequestContent::FormUrlEncoded(Box::new(connector_req))) + } + + fn build_request( + &self, + req: &types::PaymentsPreProcessingRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let req = Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .attach_default_headers() + .headers(types::PaymentsPreProcessingType::get_headers( + self, req, connectors, + )?) + .url(&types::PaymentsPreProcessingType::get_url( + self, req, connectors, + )?) + .set_body(types::PaymentsPreProcessingType::get_request_body( + self, req, connectors, + )?) + .build(), + ); + Ok(req) + } + + fn handle_response( + &self, + data: &types::PaymentsPreProcessingRouterData, + res: types::Response, + ) -> CustomResult { + let response: nmi::NmiVaultResponse = 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 { @@ -265,6 +349,91 @@ impl ConnectorIntegration for Nmi +{ + fn get_headers( + &self, + req: &types::PaymentsCompleteAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_headers(req, connectors) + } + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + fn get_url( + &self, + _req: &types::PaymentsCompleteAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}api/transact.php", self.base_url(connectors))) + } + fn get_request_body( + &self, + req: &types::PaymentsCompleteAuthorizeRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult { + let connector_router_data = nmi::NmiRouterData::try_from(( + &self.get_currency_unit(), + req.request.currency, + req.request.amount, + req, + ))?; + let connector_req = nmi::NmiCompleteReqeust::try_from(&connector_router_data)?; + Ok(RequestContent::FormUrlEncoded(Box::new(connector_req))) + } + fn build_request( + &self, + req: &types::PaymentsCompleteAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsCompleteAuthorizeType::get_url( + self, req, connectors, + )?) + .attach_default_headers() + .headers(types::PaymentsCompleteAuthorizeType::get_headers( + self, req, connectors, + )?) + .set_body(types::PaymentsCompleteAuthorizeType::get_request_body( + self, req, connectors, + )?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsCompleteAuthorizeRouterData, + res: types::Response, + ) -> CustomResult { + let response: nmi::NmiCompleteResponse = 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 { diff --git a/crates/router/src/connector/nmi/transformers.rs b/crates/router/src/connector/nmi/transformers.rs index 35c0e102020..454d041ac47 100644 --- a/crates/router/src/connector/nmi/transformers.rs +++ b/crates/router/src/connector/nmi/transformers.rs @@ -1,12 +1,13 @@ use cards::CardNumber; -use common_utils::ext_traits::XmlExt; +use common_utils::{errors::CustomResult, ext_traits::XmlExt}; use error_stack::{IntoReport, Report, ResultExt}; -use masking::Secret; +use masking::{ExposeInterface, Secret}; use serde::{Deserialize, Serialize}; use crate::{ - connector::utils::{self, PaymentsAuthorizeRequestData}, + connector::utils::{self, PaymentsAuthorizeRequestData, PaymentsCompleteAuthorizeRequestData}, core::errors, + services, types::{self, api, storage::enums, transformers::ForeignFrom, ConnectorAuthType}, }; @@ -25,17 +26,22 @@ pub enum TransactionType { pub struct NmiAuthType { pub(super) api_key: Secret, + pub(super) public_key: Option>, } 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 { + match auth_type { + types::ConnectorAuthType::HeaderKey { api_key } => Ok(Self { api_key: api_key.to_owned(), - }) - } else { - Err(errors::ConnectorError::FailedToObtainAuthType.into()) + public_key: None, + }), + types::ConnectorAuthType::BodyKey { api_key, key1 } => Ok(Self { + api_key: api_key.to_owned(), + public_key: Some(key1.to_owned()), + }), + _ => Err(errors::ConnectorError::FailedToObtainAuthType.into()), } } } @@ -71,6 +77,292 @@ impl } } +#[derive(Debug, Serialize)] +pub struct NmiVaultRequest { + security_key: Secret, + ccnumber: CardNumber, + ccexp: Secret, + customer_vault: CustomerAction, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CustomerAction { + AddCustomer, + UpdateCustomer, +} + +impl TryFrom<&types::PaymentsPreProcessingRouterData> for NmiVaultRequest { + type Error = Error; + fn try_from(item: &types::PaymentsPreProcessingRouterData) -> Result { + let auth_type: NmiAuthType = (&item.connector_auth_type).try_into()?; + let (ccnumber, ccexp) = get_card_details(item.request.payment_method_data.clone())?; + + Ok(Self { + security_key: auth_type.api_key, + ccnumber, + ccexp, + customer_vault: CustomerAction::AddCustomer, + }) + } +} + +fn get_card_details( + payment_method_data: Option, +) -> CustomResult<(CardNumber, Secret), errors::ConnectorError> { + match payment_method_data { + Some(api::PaymentMethodData::Card(ref card_details)) => Ok(( + card_details.card_number.clone(), + utils::CardData::get_card_expiry_month_year_2_digit_with_delimiter( + card_details, + "".to_string(), + ), + )), + _ => Err(errors::ConnectorError::NotSupported { + message: utils::SELECTED_PAYMENT_METHOD.to_string(), + connector: "nmi", + }) + .into_report(), + } +} + +#[derive(Debug, Deserialize)] +pub struct NmiVaultResponse { + pub response: Response, + pub responsetext: String, + pub customer_vault_id: String, + pub response_code: String, +} + +impl + TryFrom< + types::ResponseRouterData< + api::PreProcessing, + NmiVaultResponse, + types::PaymentsPreProcessingData, + types::PaymentsResponseData, + >, + > for types::PaymentsPreProcessingRouterData +{ + type Error = Error; + fn try_from( + item: types::ResponseRouterData< + api::PreProcessing, + NmiVaultResponse, + types::PaymentsPreProcessingData, + types::PaymentsResponseData, + >, + ) -> Result { + let auth_type: NmiAuthType = (&item.data.connector_auth_type).try_into()?; + let amount_data = + item.data + .request + .amount + .ok_or(errors::ConnectorError::MissingRequiredField { + field_name: "amount", + })?; + let currency_data = + item.data + .request + .currency + .ok_or(errors::ConnectorError::MissingRequiredField { + field_name: "currency", + })?; + let (response, status) = match item.response.response { + Response::Approved => ( + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::NoResponseId, + redirection_data: Some(services::RedirectForm::Nmi { + amount: utils::to_currency_base_unit_asf64( + amount_data, + currency_data.to_owned(), + )? + .to_string(), + currency: currency_data, + customer_vault_id: item.response.customer_vault_id, + public_key: auth_type.public_key.ok_or( + errors::ConnectorError::InvalidConnectorConfig { + config: "public_key", + }, + )?, + }), + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + connector_response_reference_id: None, + incremental_authorization_allowed: None, + }), + enums::AttemptStatus::AuthenticationPending, + ), + Response::Declined | Response::Error => ( + Err(types::ErrorResponse { + code: item.response.response_code, + message: item.response.responsetext, + reason: None, + status_code: item.http_code, + attempt_status: None, + connector_transaction_id: None, + }), + enums::AttemptStatus::Failure, + ), + }; + Ok(Self { + status, + response, + ..item.data + }) + } +} + +#[derive(Debug, Serialize)] +pub struct NmiCompleteReqeust { + #[serde(rename = "type")] + transaction_type: TransactionType, + security_key: Secret, + cardholder_auth: CardHolderAuthType, + cavv: String, + xid: String, + three_ds_version: ThreeDsVersion, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CardHolderAuthType { + Verified, + Attempted, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum ThreeDsVersion { + #[serde(rename = "2.0.0")] + VersionTwo, + #[serde(rename = "2.2.0")] + VersionTwoPointTwo, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NmiRedirectResponseData { + cavv: String, + xid: String, + card_holder_auth: CardHolderAuthType, + three_ds_version: ThreeDsVersion, +} + +impl TryFrom<&NmiRouterData<&types::PaymentsCompleteAuthorizeRouterData>> for NmiCompleteReqeust { + type Error = Error; + fn try_from( + item: &NmiRouterData<&types::PaymentsCompleteAuthorizeRouterData>, + ) -> Result { + let transaction_type = match item.router_data.request.is_auto_capture()? { + true => TransactionType::Sale, + false => TransactionType::Auth, + }; + let auth_type: NmiAuthType = (&item.router_data.connector_auth_type).try_into()?; + let payload_data = item + .router_data + .request + .get_redirect_response_payload()? + .expose(); + + let three_ds_data: NmiRedirectResponseData = serde_json::from_value(payload_data) + .into_report() + .change_context(errors::ConnectorError::MissingConnectorRedirectionPayload { + field_name: "cavv", + })?; + + Ok(Self { + transaction_type, + security_key: auth_type.api_key, + cardholder_auth: three_ds_data.card_holder_auth, + cavv: three_ds_data.cavv, + xid: three_ds_data.xid, + three_ds_version: three_ds_data.three_ds_version, + }) + } +} + +#[derive(Debug, Deserialize)] +pub struct NmiCompleteResponse { + 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< + api::CompleteAuthorize, + NmiCompleteResponse, + types::CompleteAuthorizeData, + types::PaymentsResponseData, + >, + > for types::PaymentsCompleteAuthorizeRouterData +{ + type Error = Error; + fn try_from( + item: types::ResponseRouterData< + api::CompleteAuthorize, + NmiCompleteResponse, + types::CompleteAuthorizeData, + 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, + connector_response_reference_id: None, + incremental_authorization_allowed: None, + }), + if let Some(diesel_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 ForeignFrom<(NmiCompleteResponse, u16)> for types::ErrorResponse { + fn foreign_from((response, http_code): (NmiCompleteResponse, u16)) -> Self { + Self { + code: response.response_code, + message: response.responsetext, + reason: None, + status_code: http_code, + attempt_status: None, + connector_transaction_id: None, + } + } +} + #[derive(Debug, Serialize)] pub struct NmiPaymentsRequest { #[serde(rename = "type")] diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index 44c46732529..bfd747640d3 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -1479,6 +1479,13 @@ where let is_error_in_response = router_data.response.is_err(); // If is_error_in_response is true, should_continue_payment should be false, we should throw the error (router_data, !is_error_in_response) + } else if connector.connector_name == router_types::Connector::Nmi + && !matches!(format!("{operation:?}").as_str(), "CompleteAuthorize") + && router_data.auth_type == storage_enums::AuthenticationType::ThreeDs + { + router_data = router_data.preprocessing_steps(state, connector).await?; + + (router_data, false) } else { (router_data, should_continue_payment) } diff --git a/crates/router/src/core/payments/flows.rs b/crates/router/src/core/payments/flows.rs index 394051f1432..ec8e13cff50 100644 --- a/crates/router/src/core/payments/flows.rs +++ b/crates/router/src/core/payments/flows.rs @@ -165,7 +165,6 @@ default_imp_for_complete_authorize!( connector::Klarna, connector::Multisafepay, connector::Nexinets, - connector::Nmi, connector::Noon, connector::Opayo, connector::Opennode, @@ -886,7 +885,6 @@ default_imp_for_pre_processing_steps!( connector::Mollie, connector::Multisafepay, connector::Nexinets, - connector::Nmi, connector::Noon, connector::Nuvei, connector::Opayo, diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index ea254ee4fab..97f17274012 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -12,6 +12,7 @@ use std::{ use actix_web::{body, web, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError}; use api_models::enums::CaptureMethod; pub use client::{proxy_bypass_urls, ApiClient, MockApiClient, ProxyClient}; +use common_enums::Currency; pub use common_utils::request::{ContentType, Method, Request, RequestBuilder}; use common_utils::{ consts::X_HS_LATENCY, @@ -19,7 +20,7 @@ use common_utils::{ request::RequestContent, }; use error_stack::{report, IntoReport, Report, ResultExt}; -use masking::PeekInterface; +use masking::{PeekInterface, Secret}; use router_env::{instrument, tracing, tracing_actix_web::RequestId, Tag}; use serde::Serialize; use serde_json::json; @@ -781,6 +782,12 @@ pub enum RedirectForm { card_token: String, bin: String, }, + Nmi { + amount: String, + currency: Currency, + public_key: Secret, + customer_vault_id: String, + }, } impl From<(url::Url, Method)> for RedirectForm { @@ -1504,6 +1511,79 @@ pub fn build_redirection_form( ))) }} } + RedirectForm::Nmi { + amount, + currency, + public_key, + customer_vault_id, + } => { + let public_key_val = public_key.peek(); + maud::html! { + (maud::DOCTYPE) + head { + (PreEscaped(r#""#)) + } + (PreEscaped(format!("" + ))) + } + } } } From f33a55d86853302d7fc6c4a3d32d01a8ae9800ce Mon Sep 17 00:00:00 2001 From: Arjun Karthik Date: Sat, 16 Dec 2023 21:46:25 +0530 Subject: [PATCH 2/3] fix: fix spell check failure --- crates/router/src/connector/nmi.rs | 2 +- crates/router/src/connector/nmi/transformers.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/router/src/connector/nmi.rs b/crates/router/src/connector/nmi.rs index e7e92955213..aecb103194f 100644 --- a/crates/router/src/connector/nmi.rs +++ b/crates/router/src/connector/nmi.rs @@ -386,7 +386,7 @@ impl req.request.amount, req, ))?; - let connector_req = nmi::NmiCompleteReqeust::try_from(&connector_router_data)?; + let connector_req = nmi::NmiCompleteRequest::try_from(&connector_router_data)?; Ok(RequestContent::FormUrlEncoded(Box::new(connector_req))) } fn build_request( diff --git a/crates/router/src/connector/nmi/transformers.rs b/crates/router/src/connector/nmi/transformers.rs index 454d041ac47..d7098d24e96 100644 --- a/crates/router/src/connector/nmi/transformers.rs +++ b/crates/router/src/connector/nmi/transformers.rs @@ -215,7 +215,7 @@ impl } #[derive(Debug, Serialize)] -pub struct NmiCompleteReqeust { +pub struct NmiCompleteRequest { #[serde(rename = "type")] transaction_type: TransactionType, security_key: Secret, @@ -249,7 +249,7 @@ pub struct NmiRedirectResponseData { three_ds_version: ThreeDsVersion, } -impl TryFrom<&NmiRouterData<&types::PaymentsCompleteAuthorizeRouterData>> for NmiCompleteReqeust { +impl TryFrom<&NmiRouterData<&types::PaymentsCompleteAuthorizeRouterData>> for NmiCompleteRequest { type Error = Error; fn try_from( item: &NmiRouterData<&types::PaymentsCompleteAuthorizeRouterData>, From 2b227201b15454719a5157bee46112736142fd3b Mon Sep 17 00:00:00 2001 From: Sk Sakil Mostak Date: Sun, 17 Dec 2023 13:02:39 +0530 Subject: [PATCH 3/3] refactor: resolve comments --- crates/router/src/connector/nmi/transformers.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/router/src/connector/nmi/transformers.rs b/crates/router/src/connector/nmi/transformers.rs index d7098d24e96..b0403d11e3e 100644 --- a/crates/router/src/connector/nmi/transformers.rs +++ b/crates/router/src/connector/nmi/transformers.rs @@ -118,10 +118,9 @@ fn get_card_details( "".to_string(), ), )), - _ => Err(errors::ConnectorError::NotSupported { - message: utils::SELECTED_PAYMENT_METHOD.to_string(), - connector: "nmi", - }) + _ => Err(errors::ConnectorError::NotImplemented( + utils::get_unimplemented_payment_method_error_message("Nmi"), + )) .into_report(), } } @@ -268,7 +267,7 @@ impl TryFrom<&NmiRouterData<&types::PaymentsCompleteAuthorizeRouterData>> for Nm let three_ds_data: NmiRedirectResponseData = serde_json::from_value(payload_data) .into_report() .change_context(errors::ConnectorError::MissingConnectorRedirectionPayload { - field_name: "cavv", + field_name: "three_ds_data", })?; Ok(Self {