feat(connector): [NMI] Implement 3DS for Cards (#3143)

Co-authored-by: Arjun Karthik <m.arjunkarthik@gmail.com>
Co-authored-by: Gnanasundari24 <118818938+Gnanasundari24@users.noreply.github.com>
This commit is contained in:
Sakil Mostak
2023-12-17 14:38:37 +05:30
committed by GitHub
parent 5f53d84a8b
commit 7df45235b1
5 changed files with 556 additions and 11 deletions

View File

@ -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<Vec<(String, request::Maskable<String>)>, 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<String, errors::ConnectorError> {
Ok(format!("{}api/transact.php", self.base_url(connectors)))
}
fn get_request_body(
&self,
req: &types::PaymentsPreProcessingRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<RequestContent, errors::ConnectorError> {
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<Option<services::Request>, 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<types::PaymentsPreProcessingRouterData, errors::ConnectorError> {
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<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::Authorize, types::PaymentsAuthorizeData, types::PaymentsResponseData> impl ConnectorIntegration<api::Authorize, types::PaymentsAuthorizeData, types::PaymentsResponseData>
for Nmi for Nmi
{ {
@ -265,6 +349,91 @@ impl ConnectorIntegration<api::Authorize, types::PaymentsAuthorizeData, types::P
} }
} }
impl api::PaymentsCompleteAuthorize for Nmi {}
impl
ConnectorIntegration<
api::CompleteAuthorize,
types::CompleteAuthorizeData,
types::PaymentsResponseData,
> for Nmi
{
fn get_headers(
&self,
req: &types::PaymentsCompleteAuthorizeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, request::Maskable<String>)>, 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<String, errors::ConnectorError> {
Ok(format!("{}api/transact.php", self.base_url(connectors)))
}
fn get_request_body(
&self,
req: &types::PaymentsCompleteAuthorizeRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<RequestContent, errors::ConnectorError> {
let connector_router_data = nmi::NmiRouterData::try_from((
&self.get_currency_unit(),
req.request.currency,
req.request.amount,
req,
))?;
let connector_req = nmi::NmiCompleteRequest::try_from(&connector_router_data)?;
Ok(RequestContent::FormUrlEncoded(Box::new(connector_req)))
}
fn build_request(
&self,
req: &types::PaymentsCompleteAuthorizeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, 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<types::PaymentsCompleteAuthorizeRouterData, errors::ConnectorError> {
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<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl ConnectorIntegration<api::PSync, types::PaymentsSyncData, types::PaymentsResponseData> impl ConnectorIntegration<api::PSync, types::PaymentsSyncData, types::PaymentsResponseData>
for Nmi for Nmi
{ {

View File

@ -1,12 +1,13 @@
use cards::CardNumber; use cards::CardNumber;
use common_utils::ext_traits::XmlExt; use common_utils::{errors::CustomResult, ext_traits::XmlExt};
use error_stack::{IntoReport, Report, ResultExt}; use error_stack::{IntoReport, Report, ResultExt};
use masking::Secret; use masking::{ExposeInterface, Secret};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
connector::utils::{self, PaymentsAuthorizeRequestData}, connector::utils::{self, PaymentsAuthorizeRequestData, PaymentsCompleteAuthorizeRequestData},
core::errors, core::errors,
services,
types::{self, api, storage::enums, transformers::ForeignFrom, ConnectorAuthType}, types::{self, api, storage::enums, transformers::ForeignFrom, ConnectorAuthType},
}; };
@ -25,17 +26,22 @@ pub enum TransactionType {
pub struct NmiAuthType { pub struct NmiAuthType {
pub(super) api_key: Secret<String>, pub(super) api_key: Secret<String>,
pub(super) public_key: Option<Secret<String>>,
} }
impl TryFrom<&ConnectorAuthType> for NmiAuthType { impl TryFrom<&ConnectorAuthType> for NmiAuthType {
type Error = Error; type Error = Error;
fn try_from(auth_type: &ConnectorAuthType) -> Result<Self, Self::Error> { fn try_from(auth_type: &ConnectorAuthType) -> Result<Self, Self::Error> {
if let types::ConnectorAuthType::HeaderKey { api_key } = auth_type { match auth_type {
Ok(Self { types::ConnectorAuthType::HeaderKey { api_key } => Ok(Self {
api_key: api_key.to_owned(), api_key: api_key.to_owned(),
}) public_key: None,
} else { }),
Err(errors::ConnectorError::FailedToObtainAuthType.into()) 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,291 @@ impl<T>
} }
} }
#[derive(Debug, Serialize)]
pub struct NmiVaultRequest {
security_key: Secret<String>,
ccnumber: CardNumber,
ccexp: Secret<String>,
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<Self, Self::Error> {
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<api::PaymentMethodData>,
) -> CustomResult<(CardNumber, Secret<String>), 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::NotImplemented(
utils::get_unimplemented_payment_method_error_message("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<Self, Self::Error> {
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 NmiCompleteRequest {
#[serde(rename = "type")]
transaction_type: TransactionType,
security_key: Secret<String>,
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 NmiCompleteRequest {
type Error = Error;
fn try_from(
item: &NmiRouterData<&types::PaymentsCompleteAuthorizeRouterData>,
) -> Result<Self, Self::Error> {
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: "three_ds_data",
})?;
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<String>,
pub transactionid: String,
pub avsresponse: Option<String>,
pub cvvresponse: Option<String>,
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<Self, Self::Error> {
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)] #[derive(Debug, Serialize)]
pub struct NmiPaymentsRequest { pub struct NmiPaymentsRequest {
#[serde(rename = "type")] #[serde(rename = "type")]

View File

@ -1479,6 +1479,13 @@ where
let is_error_in_response = router_data.response.is_err(); 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 // If is_error_in_response is true, should_continue_payment should be false, we should throw the error
(router_data, !is_error_in_response) (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 { } else {
(router_data, should_continue_payment) (router_data, should_continue_payment)
} }

View File

@ -165,7 +165,6 @@ default_imp_for_complete_authorize!(
connector::Klarna, connector::Klarna,
connector::Multisafepay, connector::Multisafepay,
connector::Nexinets, connector::Nexinets,
connector::Nmi,
connector::Noon, connector::Noon,
connector::Opayo, connector::Opayo,
connector::Opennode, connector::Opennode,
@ -886,7 +885,6 @@ default_imp_for_pre_processing_steps!(
connector::Mollie, connector::Mollie,
connector::Multisafepay, connector::Multisafepay,
connector::Nexinets, connector::Nexinets,
connector::Nmi,
connector::Noon, connector::Noon,
connector::Nuvei, connector::Nuvei,
connector::Opayo, connector::Opayo,

View File

@ -12,6 +12,7 @@ use std::{
use actix_web::{body, web, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError}; use actix_web::{body, web, FromRequest, HttpRequest, HttpResponse, Responder, ResponseError};
use api_models::enums::CaptureMethod; use api_models::enums::CaptureMethod;
pub use client::{proxy_bypass_urls, ApiClient, MockApiClient, ProxyClient}; pub use client::{proxy_bypass_urls, ApiClient, MockApiClient, ProxyClient};
use common_enums::Currency;
pub use common_utils::request::{ContentType, Method, Request, RequestBuilder}; pub use common_utils::request::{ContentType, Method, Request, RequestBuilder};
use common_utils::{ use common_utils::{
consts::X_HS_LATENCY, consts::X_HS_LATENCY,
@ -19,7 +20,7 @@ use common_utils::{
request::RequestContent, request::RequestContent,
}; };
use error_stack::{report, IntoReport, Report, ResultExt}; 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 router_env::{instrument, tracing, tracing_actix_web::RequestId, Tag};
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
@ -772,6 +773,12 @@ pub enum RedirectForm {
card_token: String, card_token: String,
bin: String, bin: String,
}, },
Nmi {
amount: String,
currency: Currency,
public_key: Secret<String>,
customer_vault_id: String,
},
} }
impl From<(url::Url, Method)> for RedirectForm { impl From<(url::Url, Method)> for RedirectForm {
@ -1495,6 +1502,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#"<script src="https://secure.networkmerchants.com/js/v1/Gateway.js"></script>"#))
}
(PreEscaped(format!("<script>
const gateway = Gateway.create('{public_key_val}');
// Initialize the ThreeDSService
const threeDS = gateway.get3DSecure();
const options = {{
customerVaultId: '{customer_vault_id}',
currency: '{currency}',
amount: '{amount}'
}};
var responseForm = document.createElement('form');
responseForm.action=window.location.pathname.replace(/payments\\/redirect\\/(\\w+)\\/(\\w+)\\/\\w+/, \"payments/$1/$2/redirect/complete/nmi\");
responseForm.method='POST';
const threeDSsecureInterface = threeDS.createUI(options);
threeDSsecureInterface.start('body');
threeDSsecureInterface.on('challenge', function(e) {{
console.log('Challenged');
}});
threeDSsecureInterface.on('complete', function(e) {{
var item1=document.createElement('input');
item1.type='hidden';
item1.name='cavv';
item1.value=e.cavv;
responseForm.appendChild(item1);
var item2=document.createElement('input');
item2.type='hidden';
item2.name='xid';
item2.value=e.xid;
responseForm.appendChild(item2);
var item3=document.createElement('input');
item3.type='hidden';
item3.name='cardHolderAuth';
item3.value=e.cardHolderAuth;
responseForm.appendChild(item3);
var item4=document.createElement('input');
item4.type='hidden';
item4.name='threeDsVersion';
item4.value=e.threeDsVersion;
responseForm.appendChild(item4);
document.body.appendChild(responseForm);
responseForm.submit();
}});
threeDSsecureInterface.on('failure', function(e) {{
responseForm.submit();
}});
</script>"
)))
}
}
} }
} }