diff --git a/crates/api_models/src/webhooks.rs b/crates/api_models/src/webhooks.rs index 3558ffc20a..98e9e0c3e3 100644 --- a/crates/api_models/src/webhooks.rs +++ b/crates/api_models/src/webhooks.rs @@ -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; diff --git a/crates/common_utils/src/crypto.rs b/crates/common_utils/src/crypto.rs index 87eb99ce15..f56746a3fa 100644 --- a/crates/common_utils/src/crypto.rs +++ b/crates/common_utils/src/crypto.rs @@ -268,6 +268,21 @@ impl GenerateDigest for Sha256 { } } +impl VerifySignature for Sha256 { + fn verify_signature( + &self, + _secret: &[u8], + signature: &[u8], + msg: &[u8], + ) -> CustomResult { + 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(); diff --git a/crates/common_utils/src/ext_traits.rs b/crates/common_utils/src/ext_traits.rs index 3bdf245451..c68b57e0f8 100644 --- a/crates/common_utils/src/ext_traits.rs +++ b/crates/common_utils/src/ext_traits.rs @@ -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 StringExt for String { serde_json::from_str::(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) + }) } } diff --git a/crates/router/src/connector/nuvei.rs b/crates/router/src/connector/nuvei.rs index 5fb4e94040..5a5fde47e1 100644 --- a/crates/router/src/connector/nuvei.rs +++ b/crates/router/src/connector/nuvei.rs @@ -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, + ) -> CustomResult, errors::ConnectorError> { + Ok(Box::new(crypto::Sha256)) + } + + fn get_webhook_source_verification_signature( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, 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, 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, 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 { - 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 { - 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 { - 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::::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( diff --git a/crates/router/src/connector/nuvei/transformers.rs b/crates/router/src/connector/nuvei/transformers.rs index b4bceacfbe..fda2a14531 100644 --- a/crates/router/src/connector/nuvei/transformers.rs +++ b/crates/router/src/connector/nuvei/transformers.rs @@ -751,7 +751,7 @@ impl From for enums::AttemptStatus { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NuveiPaymentsResponse { pub order_id: Option, @@ -841,57 +841,50 @@ impl fn try_from( item: types::ResponseRouterData, ) -> Result { - 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( + error_code: Option, + error_msg: Option, + http_code: u16, +) -> Result { + 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, + #[serde(rename = "ppp_TransactionID")] + pub ppp_transaction_id: String, + #[serde(rename = "TransactionId")] + pub transaction_id: Option, + pub userid: Option, + pub merchant_unique_id: Option, + #[serde(rename = "customData")] + pub custom_data: Option, + #[serde(rename = "productId")] + pub product_id: String, + pub first_name: Option, + pub last_name: Option, + pub email: Option, + #[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, +} + +#[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 for NuveiTransactionStatus { + fn from(status: NuveiWebhookStatus) -> Self { + match status { + NuveiWebhookStatus::Approved => Self::Approved, + NuveiWebhookStatus::Declined => Self::Declined, + _ => Self::Processing, + } + } +} + +impl From 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() + } + } +} diff --git a/crates/router/src/core/payments/transformers.rs b/crates/router/src/core/payments/transformers.rs index a7662a3001..ce40376d11 100644 --- a/crates/router/src/core/payments/transformers.rs +++ b/crates/router/src/core/payments/transformers.rs @@ -477,7 +477,7 @@ impl TryFrom> 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, }) diff --git a/crates/router/src/core/webhooks.rs b/crates/router/src/core/webhooks.rs index 81f68474dd..72bda6d1bb 100644 --- a/crates/router/src/core/webhooks.rs +++ b/crates/router/src/core/webhooks.rs @@ -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( .attach_printable("Failed construction of ConnectorData")?; let connector = connector.connector; + let query_params = Some(req.query_string().to_string()); + let qp: HashMap = + url::form_urlencoded::parse(query_params.unwrap_or_default().as_bytes()) + .into_owned() + .collect(); + let json = Encode::>::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, };