feat(webhook): add frm webhook support (#4662)

Signed-off-by: chikke srujan <121822803+srujanchikke@users.noreply.github.com>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
chikke srujan
2024-05-22 13:45:01 +05:30
committed by GitHub
parent 2ad7fc0cd6
commit ae601e8e1b
7 changed files with 400 additions and 38 deletions

View File

@ -599,3 +599,8 @@ pub fn convert_pm_auth_connector(connector_name: &str) -> Option<PmAuthConnector
pub fn convert_authentication_connector(connector_name: &str) -> Option<AuthenticationConnectors> {
AuthenticationConnectors::from_str(connector_name).ok()
}
#[cfg(feature = "frm")]
pub fn convert_frm_connector(connector_name: &str) -> Option<FrmConnectors> {
FrmConnectors::from_str(connector_name).ok()
}

View File

@ -39,6 +39,8 @@ pub enum IncomingWebhookEvent {
MandateRevoked,
EndpointVerification,
ExternalAuthenticationARes,
FrmApproved,
FrmRejected,
}
pub enum WebhookFlow {
@ -50,6 +52,7 @@ pub enum WebhookFlow {
BankTransfer,
Mandate,
ExternalAuthentication,
FraudCheck,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
@ -119,6 +122,9 @@ impl From<IncomingWebhookEvent> for WebhookFlow {
IncomingWebhookEvent::SourceChargeable
| IncomingWebhookEvent::SourceTransactionCreated => Self::BankTransfer,
IncomingWebhookEvent::ExternalAuthenticationARes => Self::ExternalAuthentication,
IncomingWebhookEvent::FrmApproved | IncomingWebhookEvent::FrmRejected => {
Self::FraudCheck
}
}
}
}

View File

@ -2,19 +2,24 @@ pub mod transformers;
use std::fmt::Debug;
#[cfg(feature = "frm")]
use common_utils::request::RequestContent;
use error_stack::{report, ResultExt};
use base64::Engine;
#[cfg(feature = "frm")]
use common_utils::{crypto, ext_traits::ByteSliceExt, request::RequestContent};
#[cfg(feature = "frm")]
use error_stack::ResultExt;
#[cfg(feature = "frm")]
use masking::{ExposeInterface, PeekInterface};
#[cfg(feature = "frm")]
use ring::hmac;
#[cfg(feature = "frm")]
use transformers as riskified;
#[cfg(feature = "frm")]
use super::utils::FrmTransactionRouterDataRequest;
use super::utils::{self as connector_utils, FrmTransactionRouterDataRequest};
use crate::{
configs::settings,
core::errors::{self, CustomResult},
headers,
services::{self, request, ConnectorIntegration, ConnectorValidation},
services::{self, ConnectorIntegration, ConnectorValidation},
types::{
self,
api::{self, ConnectorCommon, ConnectorCommonExt},
@ -22,8 +27,13 @@ use crate::{
};
#[cfg(feature = "frm")]
use crate::{
consts,
events::connector_api_logs::ConnectorEvent,
types::{api::fraud_check as frm_api, fraud_check as frm_types, ErrorResponse, Response},
headers,
services::request,
types::{
api::fraud_check as frm_api, domain, fraud_check as frm_types, ErrorResponse, Response,
},
utils::BytesExt,
};
@ -31,6 +41,7 @@ use crate::{
pub struct Riskified;
impl Riskified {
#[cfg(feature = "frm")]
pub fn generate_authorization_signature(
&self,
auth: &riskified::RiskifiedAuthType,
@ -53,6 +64,7 @@ impl<Flow, Request, Response> ConnectorCommonExt<Flow, Request, Response> for Ri
where
Self: ConnectorIntegration<Flow, Request, Response>,
{
#[cfg(feature = "frm")]
fn build_headers(
&self,
req: &types::RouterData<Flow, Request, Response>,
@ -122,7 +134,7 @@ impl ConnectorCommon for Riskified {
Ok(ErrorResponse {
status_code: res.status_code,
attempt_status: None,
code: crate::consts::NO_ERROR_CODE.to_string(),
code: consts::NO_ERROR_CODE.to_string(),
message: response.error.message.clone(),
reason: None,
connector_transaction_id: None,
@ -268,11 +280,7 @@ impl
self.base_url(connectors),
"/checkout_denied"
)),
Some(true) => Ok(format!("{}{}", self.base_url(connectors), "/decision")),
None => Err(errors::ConnectorError::FlowNotSupported {
flow: "Transaction".to_owned(),
connector: req.connector.to_string(),
})?,
_ => Ok(format!("{}{}", self.base_url(connectors), "/decision")),
}
}
@ -286,14 +294,10 @@ impl
let req_obj = riskified::TransactionFailedRequest::try_from(req)?;
Ok(RequestContent::Json(Box::new(req_obj)))
}
Some(true) => {
_ => {
let req_obj = riskified::TransactionSuccessRequest::try_from(req)?;
Ok(RequestContent::Json(Box::new(req_obj)))
}
None => Err(errors::ConnectorError::FlowNotSupported {
flow: "Transaction".to_owned(),
connector: req.connector.to_owned(),
})?,
}
}
@ -545,26 +549,101 @@ impl frm_api::FraudCheckFulfillment for Riskified {}
#[cfg(feature = "frm")]
impl frm_api::FraudCheckRecordReturn for Riskified {}
#[cfg(feature = "frm")]
#[async_trait::async_trait]
impl api::IncomingWebhook for Riskified {
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::HmacSha256))
}
fn get_webhook_source_verification_signature(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
_connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let header_value =
connector_utils::get_header_key_value("x-riskified-hmac-sha256", request.headers)?;
Ok(header_value.as_bytes().to_vec())
}
async fn verify_webhook_source(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
merchant_account: &domain::MerchantAccount,
merchant_connector_account: domain::MerchantConnectorAccount,
connector_label: &str,
) -> CustomResult<bool, errors::ConnectorError> {
let connector_webhook_secrets = self
.get_webhook_source_verification_merchant_secret(
merchant_account,
connector_label,
merchant_connector_account,
)
.await
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?;
let signature = self
.get_webhook_source_verification_signature(request, &connector_webhook_secrets)
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?;
let message = self
.get_webhook_source_verification_message(
request,
&merchant_account.merchant_id,
&connector_webhook_secrets,
)
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?;
let signing_key = hmac::Key::new(hmac::HMAC_SHA256, &connector_webhook_secrets.secret);
let signed_message = hmac::sign(&signing_key, &message);
let payload_sign = consts::BASE64_ENGINE.encode(signed_message.as_ref());
Ok(payload_sign.as_bytes().eq(&signature))
}
fn get_webhook_source_verification_message(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
_merchant_id: &str,
_connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
Ok(request.body.to_vec())
}
fn get_webhook_object_reference_id(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
let resource: riskified::RiskifiedWebhookBody = request
.body
.parse_struct("RiskifiedWebhookBody")
.change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?;
Ok(api::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::PaymentAttemptId(resource.id),
))
}
fn get_webhook_event_type(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::IncomingWebhookEvent, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
let resource: riskified::RiskifiedWebhookBody = request
.body
.parse_struct("RiskifiedWebhookBody")
.change_context(errors::ConnectorError::WebhookEventTypeNotFound)?;
Ok(api::IncomingWebhookEvent::from(resource.status))
}
fn get_webhook_resource_object(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn masking::ErasedMaskSerialize>, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
let resource: riskified::RiskifiedWebhookBody = request
.body
.parse_struct("RiskifiedWebhookBody")
.change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?;
Ok(Box::new(resource))
}
}

View File

@ -11,7 +11,7 @@ use crate::{
},
core::{errors, fraud_check::types as core_types},
types::{
self, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums,
self, api, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums,
ResponseId, ResponseRouterData,
},
};
@ -142,8 +142,9 @@ impl TryFrom<&frm_types::FrmCheckoutRouterData> for RiskifiedPaymentsCheckoutReq
field_name: "frm_metadata",
})?
.parse_value("Riskified Metadata")
.change_context(errors::ConnectorError::RequestEncodingFailed)?;
.change_context(errors::ConnectorError::InvalidDataFormat {
field_name: "frm_metadata",
})?;
let billing_address = payment_data.get_billing()?;
let shipping_address = payment_data.get_shipping_address_with_phone_number()?;
let address = payment_data.get_billing_address()?;
@ -606,3 +607,25 @@ fn get_fulfillment_status(
core_types::FulfillmentStatus::PARTIAL | core_types::FulfillmentStatus::REPLACEMENT => None,
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RiskifiedWebhookBody {
pub id: String,
pub status: RiskifiedWebhookStatus,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum RiskifiedWebhookStatus {
Approved,
Declined,
}
impl From<RiskifiedWebhookStatus> for api::IncomingWebhookEvent {
fn from(value: RiskifiedWebhookStatus) -> Self {
match value {
RiskifiedWebhookStatus::Declined => Self::FrmRejected,
RiskifiedWebhookStatus::Approved => Self::FrmApproved,
}
}
}

View File

@ -1,16 +1,23 @@
pub mod transformers;
use std::fmt::Debug;
#[cfg(feature = "frm")]
use base64::Engine;
#[cfg(feature = "frm")]
use common_utils::request::RequestContent;
use error_stack::{report, ResultExt};
use common_utils::{crypto, ext_traits::ByteSliceExt, request::RequestContent};
#[cfg(feature = "frm")]
use error_stack::ResultExt;
#[cfg(feature = "frm")]
use masking::PeekInterface;
#[cfg(feature = "frm")]
use ring::hmac;
#[cfg(feature = "frm")]
use transformers as signifyd;
#[cfg(feature = "frm")]
use super::utils as connector_utils;
use crate::{
configs::settings,
consts,
core::errors::{self, CustomResult},
headers,
services::{self, request, ConnectorIntegration, ConnectorValidation},
@ -21,8 +28,11 @@ use crate::{
};
#[cfg(feature = "frm")]
use crate::{
consts,
events::connector_api_logs::ConnectorEvent,
types::{api::fraud_check as frm_api, fraud_check as frm_types, ErrorResponse, Response},
types::{
api::fraud_check as frm_api, domain, fraud_check as frm_types, ErrorResponse, Response,
},
utils::BytesExt,
};
@ -61,6 +71,7 @@ impl ConnectorCommon for Signifyd {
connectors.signifyd.base_url.as_ref()
}
#[cfg(feature = "frm")]
fn get_auth_header(
&self,
auth_type: &types::ConnectorAuthType,
@ -646,26 +657,101 @@ impl
}
}
#[cfg(feature = "frm")]
#[async_trait::async_trait]
impl api::IncomingWebhook for Signifyd {
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::HmacSha256))
}
fn get_webhook_source_verification_signature(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
_connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let header_value =
connector_utils::get_header_key_value("x-signifyd-sec-hmac-sha256", request.headers)?;
Ok(header_value.as_bytes().to_vec())
}
fn get_webhook_source_verification_message(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
_merchant_id: &str,
_connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
Ok(request.body.to_vec())
}
async fn verify_webhook_source(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
merchant_account: &domain::MerchantAccount,
merchant_connector_account: domain::MerchantConnectorAccount,
connector_label: &str,
) -> CustomResult<bool, errors::ConnectorError> {
let connector_webhook_secrets = self
.get_webhook_source_verification_merchant_secret(
merchant_account,
connector_label,
merchant_connector_account,
)
.await
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?;
let signature = self
.get_webhook_source_verification_signature(request, &connector_webhook_secrets)
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?;
let message = self
.get_webhook_source_verification_message(
request,
&merchant_account.merchant_id,
&connector_webhook_secrets,
)
.change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?;
let signing_key = hmac::Key::new(hmac::HMAC_SHA256, &connector_webhook_secrets.secret);
let signed_message = hmac::sign(&signing_key, &message);
let payload_sign = consts::BASE64_ENGINE.encode(signed_message.as_ref());
Ok(payload_sign.as_bytes().eq(&signature))
}
fn get_webhook_object_reference_id(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
let resource: signifyd::SignifydWebhookBody = request
.body
.parse_struct("SignifydWebhookBody")
.change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?;
Ok(api::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::PaymentAttemptId(resource.order_id),
))
}
fn get_webhook_event_type(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::IncomingWebhookEvent, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
let resource: signifyd::SignifydWebhookBody = request
.body
.parse_struct("SignifydWebhookBody")
.change_context(errors::ConnectorError::WebhookEventTypeNotFound)?;
Ok(api::IncomingWebhookEvent::from(resource.review_disposition))
}
fn get_webhook_resource_object(
&self,
_request: &api::IncomingWebhookRequestDetails<'_>,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn masking::ErasedMaskSerialize>, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
let resource: signifyd::SignifydWebhookBody = request
.body
.parse_struct("SignifydWebhookBody")
.change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?;
Ok(Box::new(resource))
}
}

View File

@ -13,7 +13,7 @@ use crate::{
},
core::{errors, fraud_check::types as core_types},
types::{
self, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums,
self, api, api::Fulfillment, fraud_check as frm_types, storage::enums as storage_enums,
ResponseId, ResponseRouterData,
},
};
@ -399,7 +399,9 @@ impl TryFrom<&frm_types::FrmCheckoutRouterData> for SignifydPaymentsCheckoutRequ
field_name: "frm_metadata",
})?
.parse_value("Signifyd Frm Metadata")
.change_context(errors::ConnectorError::RequestEncodingFailed)?;
.change_context(errors::ConnectorError::InvalidDataFormat {
field_name: "frm_metadata",
})?;
let ship_address = item.get_shipping_address()?;
let street_addr = ship_address.get_line1()?;
let city_addr = ship_address.get_city()?;
@ -705,3 +707,27 @@ impl TryFrom<&frm_types::FrmRecordReturnRouterData> for SignifydPaymentsRecordRe
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignifydWebhookBody {
pub order_id: String,
pub review_disposition: ReviewDisposition,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ReviewDisposition {
Fraudulent,
Good,
}
impl From<ReviewDisposition> for api::IncomingWebhookEvent {
fn from(value: ReviewDisposition) -> Self {
match value {
ReviewDisposition::Fraudulent => Self::FrmRejected,
ReviewDisposition::Good => Self::FrmApproved,
}
}
}

View File

@ -677,6 +677,118 @@ pub async fn mandates_incoming_webhook_flow(
}
}
#[allow(clippy::too_many_arguments)]
#[instrument(skip_all)]
pub(crate) async fn frm_incoming_webhook_flow(
state: AppState,
req_state: ReqState,
merchant_account: domain::MerchantAccount,
key_store: domain::MerchantKeyStore,
source_verified: bool,
event_type: webhooks::IncomingWebhookEvent,
object_ref_id: api::ObjectReferenceId,
business_profile: diesel_models::business_profile::BusinessProfile,
) -> CustomResult<WebhookResponseTracker, errors::ApiErrorResponse> {
if source_verified {
let payment_attempt =
get_payment_attempt_from_object_reference_id(&state, object_ref_id, &merchant_account)
.await?;
let payment_response = match event_type {
webhooks::IncomingWebhookEvent::FrmApproved => {
Box::pin(payments::payments_core::<
api::Capture,
api::PaymentsResponse,
_,
_,
_,
>(
state.clone(),
req_state,
merchant_account.clone(),
key_store.clone(),
payments::PaymentApprove,
api::PaymentsCaptureRequest {
payment_id: payment_attempt.payment_id,
amount_to_capture: payment_attempt.amount_to_capture,
..Default::default()
},
services::api::AuthFlow::Merchant,
payments::CallConnectorAction::Trigger,
None,
HeaderPayload::default(),
))
.await?
}
webhooks::IncomingWebhookEvent::FrmRejected => {
Box::pin(payments::payments_core::<
api::Void,
api::PaymentsResponse,
_,
_,
_,
>(
state.clone(),
req_state,
merchant_account.clone(),
key_store.clone(),
payments::PaymentReject,
api::PaymentsCancelRequest {
payment_id: payment_attempt.payment_id.clone(),
cancellation_reason: Some(
"Rejected by merchant based on FRM decision".to_string(),
),
..Default::default()
},
services::api::AuthFlow::Merchant,
payments::CallConnectorAction::Trigger,
None,
HeaderPayload::default(),
))
.await?
}
_ => Err(errors::ApiErrorResponse::EventNotFound)?,
};
match payment_response {
services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => {
let payment_id = payments_response
.payment_id
.clone()
.get_required_value("payment_id")
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
.attach_printable("payment id not received from payments core")?;
let status = payments_response.status;
let event_type: Option<enums::EventType> = payments_response.status.foreign_into();
if let Some(outgoing_event_type) = event_type {
let primary_object_created_at = payments_response.created;
create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
&key_store,
outgoing_event_type,
enums::EventClass::Payments,
payment_id.clone(),
enums::EventObjectType::PaymentDetails,
api::OutgoingWebhookContent::PaymentDetails(payments_response),
primary_object_created_at,
)
.await?;
};
let response = WebhookResponseTracker::Payment { payment_id, status };
Ok(response)
}
_ => Err(errors::ApiErrorResponse::WebhookProcessingFailure).attach_printable(
"Did not get payment id as object reference id in webhook payments flow",
)?,
}
} else {
logger::error!("Webhook source verification failed for frm webhooks flow");
Err(report!(
errors::ApiErrorResponse::WebhookAuthenticationFailed
))
}
}
#[allow(clippy::too_many_arguments)]
#[instrument(skip_all)]
pub async fn disputes_incoming_webhook_flow(
@ -1828,6 +1940,18 @@ pub async fn webhooks_core<W: types::OutgoingWebhookType>(
.await
.attach_printable("Incoming webhook flow for external authentication failed")?
}
api::WebhookFlow::FraudCheck => Box::pin(frm_incoming_webhook_flow(
state.clone(),
req_state,
merchant_account,
key_store,
source_verified,
event_type,
object_ref_id,
business_profile,
))
.await
.attach_printable("Incoming webhook flow for fraud check failed")?,
_ => Err(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Unsupported Flow Type received in incoming webhooks")?,
@ -1901,6 +2025,19 @@ fn get_connector_by_connector_name(
) -> CustomResult<(&'static (dyn api::Connector + Sync), String), errors::ApiErrorResponse> {
let authentication_connector =
api_models::enums::convert_authentication_connector(connector_name);
#[cfg(feature = "frm")]
{
let frm_connector = api_models::enums::convert_frm_connector(connector_name);
if frm_connector.is_some() {
let frm_connector_data =
api::FraudCheckConnectorData::get_connector_by_name(connector_name)?;
return Ok((
*frm_connector_data.connector,
frm_connector_data.connector_name.to_string(),
));
}
}
let (connector, connector_name) = if authentication_connector.is_some() {
let authentication_connector_data =
api::AuthenticationConnectorData::get_connector_by_name(connector_name)?;