mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-27 11:24:45 +08:00
feat(connector): [Nuvei] add webhook support (#795)
Co-authored-by: Arjun Karthik <m.arjunkarthik@gmail.com>
This commit is contained in:
@ -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>;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user