feat(connector): [Nuvei] add webhook support (#795)

Co-authored-by: Arjun Karthik <m.arjunkarthik@gmail.com>
This commit is contained in:
Jagan
2023-03-28 18:56:32 +05:30
committed by GitHub
parent f9ef3135af
commit 20b4372bfe
7 changed files with 284 additions and 84 deletions

View File

@ -37,6 +37,8 @@ pub struct IncomingWebhookRequestDetails<'a> {
pub method: actix_web::http::Method,
pub headers: &'a actix_web::http::header::HeaderMap,
pub body: &'a [u8],
pub query_params: String,
pub query_params_json: &'a [u8],
}
pub type MerchantWebhookConfig = std::collections::HashSet<IncomingWebhookEvent>;

View File

@ -268,6 +268,21 @@ impl GenerateDigest for Sha256 {
}
}
impl VerifySignature for Sha256 {
fn verify_signature(
&self,
_secret: &[u8],
signature: &[u8],
msg: &[u8],
) -> CustomResult<bool, errors::CryptoError> {
let hashed_digest = Self
.generate_digest(msg)
.change_context(errors::CryptoError::SignatureVerificationFailed)?;
let hashed_digest_into_bytes = hashed_digest.as_slice();
Ok(hashed_digest_into_bytes == signature)
}
}
/// Generate a random string using a cryptographically secure pseudo-random number generator
/// (CSPRNG). Typically used for generating (readable) keys and passwords.
#[inline]
@ -333,6 +348,30 @@ mod crypto_tests {
assert!(!wrong_verified);
}
#[test]
fn test_sha256_verify_signature() {
let right_signature =
hex::decode("123250a72f4e961f31661dbcee0fec0f4714715dc5ae1b573f908a0a5381ddba")
.expect("Right signature decoding");
let wrong_signature =
hex::decode("123250a72f4e961f31661dbcee0fec0f4714715dc5ae1b573f908a0a5381ddbb")
.expect("Wrong signature decoding");
let secret = "".as_bytes();
let data = r#"AJHFH9349JASFJHADJ9834115USD2020-11-13.13:22:34711000000021406655APPROVED12345product_id"#.as_bytes();
let right_verified = super::Sha256
.verify_signature(secret, &right_signature, data)
.expect("Right signature verification result");
assert!(right_verified);
let wrong_verified = super::Sha256
.verify_signature(secret, &wrong_signature, data)
.expect("Wrong signature verification result");
assert!(!wrong_verified);
}
#[test]
fn test_hmac_sha512_sign_message() {
let message = r#"{"type":"payment_intent"}"#.as_bytes();

View File

@ -193,7 +193,7 @@ impl ByteSliceExt for [u8] {
serde_json::from_slice(self)
.into_report()
.change_context(errors::ParsingError)
.attach_printable_lazy(|| format!("Unable to parse {type_name} from &[u8]"))
.attach_printable_lazy(|| format!("Unable to parse {type_name} from &[u8] {:?}", &self))
}
}
@ -277,7 +277,9 @@ impl<T> StringExt<T> for String {
serde_json::from_str::<T>(self)
.into_report()
.change_context(errors::ParsingError)
.attach_printable_lazy(|| format!("Unable to parse {type_name} from string"))
.attach_printable_lazy(|| {
format!("Unable to parse {type_name} from string {:?}", &self)
})
}
}

View File

@ -3,8 +3,9 @@ mod transformers;
use std::fmt::Debug;
use ::common_utils::{
crypto,
errors::ReportSwitchExt,
ext_traits::{BytesExt, StringExt, ValueExt},
ext_traits::{BytesExt, ValueExt},
};
use error_stack::{IntoReport, ResultExt};
use transformers as nuvei;
@ -16,6 +17,7 @@ use crate::{
errors::{self, CustomResult},
payments,
},
db::StorageInterface,
headers,
services::{self, ConnectorIntegration},
types::{
@ -24,7 +26,7 @@ use crate::{
storage::enums,
ErrorResponse, Response,
},
utils::{self as common_utils},
utils::{self as common_utils, ByteSliceExt, Encode},
};
#[derive(Debug, Clone)]
@ -816,25 +818,105 @@ impl ConnectorIntegration<api::RSync, types::RefundsData, types::RefundsResponse
#[async_trait::async_trait]
impl api::IncomingWebhook for Nuvei {
fn get_webhook_object_reference_id(
fn get_webhook_source_verification_algorithm(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn crypto::VerifySignature + Send>, errors::ConnectorError> {
Ok(Box::new(crypto::Sha256))
}
fn get_webhook_source_verification_signature(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let signature = utils::get_header_key_value("advanceResponseChecksum", request.headers)?;
hex::decode(signature)
.into_report()
.change_context(errors::ConnectorError::WebhookResponseEncodingFailed)
}
async fn get_webhook_source_verification_merchant_secret(
&self,
db: &dyn StorageInterface,
merchant_id: &str,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let key = format!("wh_mer_sec_verification_{}_{}", self.id(), merchant_id);
let secret = db
.get_key(&key)
.await
.change_context(errors::ConnectorError::WebhookVerificationSecretNotFound)?;
Ok(secret)
}
fn get_webhook_source_verification_message(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
_merchant_id: &str,
secret: &[u8],
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let body: nuvei::NuveiWebhookDetails = request
.query_params_json
.parse_struct("NuveiWebhookDetails")
.switch()?;
let secret_str = std::str::from_utf8(secret)
.into_report()
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
let status = format!("{:?}", body.status).to_uppercase();
let to_sign = format!(
"{}{}{}{}{}{}{}",
secret_str,
body.total_amount,
body.currency,
body.response_time_stamp,
body.ppp_transaction_id,
status,
body.product_id
);
Ok(to_sign.into_bytes())
}
fn get_webhook_object_reference_id(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
Err(errors::ConnectorError::WebhooksNotImplemented).into_report()
let body: nuvei::NuveiWebhookTransactionId = request
.query_params_json
.parse_struct("NuveiWebhookTransactionId")
.switch()?;
Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
types::api::PaymentIdType::ConnectorTransactionId(body.ppp_transaction_id),
))
}
fn get_webhook_event_type(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::IncomingWebhookEvent, errors::ConnectorError> {
Err(errors::ConnectorError::WebhooksNotImplemented).into_report()
let body: nuvei::NuveiWebhookDataStatus = request
.query_params_json
.parse_struct("NuveiWebhookDataStatus")
.switch()?;
match body.status {
nuvei::NuveiWebhookStatus::Approved => {
Ok(api::IncomingWebhookEvent::PaymentIntentSuccess)
}
nuvei::NuveiWebhookStatus::Declined => {
Ok(api::IncomingWebhookEvent::PaymentIntentFailure)
}
_ => Err(errors::ConnectorError::WebhookEventTypeNotFound.into()),
}
}
fn get_webhook_resource_object(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<serde_json::Value, errors::ConnectorError> {
Err(errors::ConnectorError::WebhooksNotImplemented).into_report()
let body: nuvei::NuveiWebhookDetails = request
.query_params_json
.parse_struct("NuveiWebhookDetails")
.switch()?;
let payment_response = nuvei::NuveiPaymentsResponse::from(body);
Encode::<nuvei::NuveiPaymentsResponse>::encode_to_value(&payment_response).switch()
}
}
@ -851,10 +933,11 @@ impl services::ConnectorRedirectResponse for Nuvei {
if let Some(payload) = json_payload {
let redirect_response: nuvei::NuveiRedirectionResponse =
payload.parse_value("NuveiRedirectionResponse").switch()?;
let acs_response: nuvei::NuveiACSResponse = redirect_response
.cres
.parse_struct("NuveiACSResponse")
.switch()?;
let acs_response: nuvei::NuveiACSResponse =
utils::base64_decode(redirect_response.cres)?
.as_slice()
.parse_struct("NuveiACSResponse")
.switch()?;
match acs_response.trans_status {
None | Some(nuvei::LiabilityShift::Failed) => {
Ok(payments::CallConnectorAction::StatusUpdate(

View File

@ -751,7 +751,7 @@ impl From<NuveiTransactionStatus> for enums::AttemptStatus {
}
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NuveiPaymentsResponse {
pub order_id: Option<String>,
@ -841,57 +841,50 @@ impl<F, T>
fn try_from(
item: types::ResponseRouterData<F, NuveiPaymentsResponse, T, types::PaymentsResponseData>,
) -> Result<Self, Self::Error> {
let redirection_data = item
.response
.payment_option
.as_ref()
.and_then(|o| o.card.clone())
.and_then(|card| card.three_d)
.and_then(|three_ds| three_ds.acs_url.zip(three_ds.c_req))
.map(|(base_url, creq)| services::RedirectForm {
endpoint: base_url,
method: services::Method::Post,
form_fields: std::collections::HashMap::from([("creq".to_string(), creq)]),
});
Ok(Self {
status: get_payment_status(&item.response),
response: match item.response.status {
NuveiPaymentStatus::Error => Err(types::ErrorResponse {
code: item
.response
.err_code
.map(|c| c.to_string())
.unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()),
message: item
.response
.reason
.unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()),
reason: None,
status_code: item.http_code,
let redirection_data = match item.data.payment_method {
storage_models::enums::PaymentMethod::Wallet => item
.response
.payment_option
.as_ref()
.and_then(|po| po.redirect_url.clone())
.map(|base_url| services::RedirectForm::from((base_url, services::Method::Get))),
_ => item
.response
.payment_option
.as_ref()
.and_then(|o| o.card.clone())
.and_then(|card| card.three_d)
.and_then(|three_ds| three_ds.acs_url.zip(three_ds.c_req))
.map(|(base_url, creq)| services::RedirectForm {
endpoint: base_url,
method: services::Method::Post,
form_fields: std::collections::HashMap::from([("creq".to_string(), creq)]),
}),
_ => match item.response.transaction_status {
Some(NuveiTransactionStatus::Error) => Err(types::ErrorResponse {
code: item
.response
.gw_error_code
.map(|c| c.to_string())
.unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()),
message: item
.response
.gw_error_reason
.unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()),
reason: None,
status_code: item.http_code,
}),
};
let response = item.response;
Ok(Self {
status: get_payment_status(&response),
response: match response.status {
NuveiPaymentStatus::Error => {
get_error_response(response.err_code, response.reason, item.http_code)
}
_ => match response.transaction_status {
Some(NuveiTransactionStatus::Error) => get_error_response(
response.gw_error_code,
response.gw_error_reason,
item.http_code,
),
_ => Ok(types::PaymentsResponseData::TransactionResponse {
resource_id: item.response.transaction_id.map_or_else(
|| types::ResponseId::NoResponseId,
types::ResponseId::ConnectorTransactionId,
),
resource_id: response
.transaction_id
.map_or(response.order_id, Some) // For paypal there will be no transaction_id, only order_id will be present
.map(types::ResponseId::ConnectorTransactionId)
.ok_or_else(|| errors::ConnectorError::MissingConnectorTransactionID)?,
redirection_data,
mandate_reference: None,
// we don't need to save session token for capture, void flow so ignoring if it is not present
connector_metadata: if let Some(token) = item.response.session_token {
connector_metadata: if let Some(token) = response.session_token {
Some(
serde_json::to_value(NuveiMeta {
session_token: token,
@ -991,29 +984,13 @@ fn get_refund_response(
.map(enums::RefundStatus::from)
.unwrap_or(enums::RefundStatus::Failure);
match response.status {
NuveiPaymentStatus::Error => Err(types::ErrorResponse {
code: response
.err_code
.map(|c| c.to_string())
.unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()),
message: response
.reason
.unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()),
reason: None,
status_code: http_code,
}),
NuveiPaymentStatus::Error => {
get_error_response(response.err_code, response.reason, http_code)
}
_ => match response.transaction_status {
Some(NuveiTransactionStatus::Error) => Err(types::ErrorResponse {
code: response
.gw_error_code
.map(|c| c.to_string())
.unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()),
message: response
.gw_error_reason
.unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()),
reason: None,
status_code: http_code,
}),
Some(NuveiTransactionStatus::Error) => {
get_error_response(response.gw_error_code, response.gw_error_reason, http_code)
}
_ => Ok(types::RefundsResponseData {
connector_refund_id: txn_id,
refund_status,
@ -1021,3 +998,88 @@ fn get_refund_response(
},
}
}
fn get_error_response<T>(
error_code: Option<i64>,
error_msg: Option<String>,
http_code: u16,
) -> Result<T, types::ErrorResponse> {
Err(types::ErrorResponse {
code: error_code
.map(|c| c.to_string())
.unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()),
message: error_msg.unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()),
reason: None,
status_code: http_code,
})
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NuveiWebhookDetails {
pub ppp_status: Option<String>,
#[serde(rename = "ppp_TransactionID")]
pub ppp_transaction_id: String,
#[serde(rename = "TransactionId")]
pub transaction_id: Option<String>,
pub userid: Option<String>,
pub merchant_unique_id: Option<String>,
#[serde(rename = "customData")]
pub custom_data: Option<String>,
#[serde(rename = "productId")]
pub product_id: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub email: Option<String>,
#[serde(rename = "totalAmount")]
pub total_amount: String,
pub currency: String,
#[serde(rename = "responseTimeStamp")]
pub response_time_stamp: String,
#[serde(rename = "Status")]
pub status: NuveiWebhookStatus,
#[serde(rename = "transactionType")]
pub transaction_type: Option<NuveiTransactionType>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NuveiWebhookTransactionId {
#[serde(rename = "ppp_TransactionID")]
pub ppp_transaction_id: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NuveiWebhookDataStatus {
#[serde(rename = "Status")]
pub status: NuveiWebhookStatus,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum NuveiWebhookStatus {
Approved,
Declined,
#[default]
Pending,
Update,
}
impl From<NuveiWebhookStatus> for NuveiTransactionStatus {
fn from(status: NuveiWebhookStatus) -> Self {
match status {
NuveiWebhookStatus::Approved => Self::Approved,
NuveiWebhookStatus::Declined => Self::Declined,
_ => Self::Processing,
}
}
}
impl From<NuveiWebhookDetails> for NuveiPaymentsResponse {
fn from(item: NuveiWebhookDetails) -> Self {
Self {
transaction_status: Some(NuveiTransactionStatus::from(item.status)),
transaction_id: item.transaction_id,
transaction_type: item.transaction_type,
..Default::default()
}
}
}

View File

@ -477,7 +477,7 @@ impl<F: Clone> TryFrom<PaymentData<F>> for types::PaymentsAuthorizeData {
payment_experience: payment_data.payment_attempt.payment_experience,
order_details,
session_token: None,
enrolled_for_3ds: false,
enrolled_for_3ds: true,
related_transaction_id: None,
payment_method_type: payment_data.payment_attempt.payment_method_type,
})

View File

@ -1,6 +1,8 @@
pub mod transformers;
pub mod utils;
use std::collections::HashMap;
use error_stack::{IntoReport, ResultExt};
use masking::ExposeInterface;
use router_env::{instrument, tracing};
@ -318,10 +320,20 @@ pub async fn webhooks_core<W: api::OutgoingWebhookType>(
.attach_printable("Failed construction of ConnectorData")?;
let connector = connector.connector;
let query_params = Some(req.query_string().to_string());
let qp: HashMap<String, String> =
url::form_urlencoded::parse(query_params.unwrap_or_default().as_bytes())
.into_owned()
.collect();
let json = Encode::<HashMap<String, String>>::encode_to_string_of_json(&qp)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("There was an error in parsing the query params")?;
let mut request_details = api::IncomingWebhookRequestDetails {
method: req.method().clone(),
headers: req.headers(),
query_params: req.query_string().to_string(),
query_params_json: json.as_bytes(),
body: &body,
};