feat(connector): Recurly incoming webhook support (#7439)

Co-authored-by: Nishanth Challa <nishanth.challa@Nishanth-Challa-C0WGKCFHLF.local>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: CHALLA NISHANTH BABU <115225644+NISHANTH1221@users.noreply.github.com>
Co-authored-by: Aniket Burman <aniket.burman@Aniket-Burman-JDXHW2PH34.local>
Co-authored-by: Aniket Burman <aniket.burman@192.168.1.5>
Co-authored-by: Aniket Burman <aniket.burman@192.168.1.4>
This commit is contained in:
Aniket Burman
2025-03-17 14:37:27 +05:30
committed by GitHub
parent fc596eaf1c
commit 2d17dad25d
14 changed files with 144 additions and 19 deletions

View File

@ -259,7 +259,7 @@ powertranz.base_url = "https://staging.ptranz.com/api/"
prophetpay.base_url = "https://ccm-thirdparty.cps.golf/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
razorpay.base_url = "https://sandbox.juspay.in/"
recurly.base_url = "https://{{merchant_subdomain_name}}.recurly.com"
recurly.base_url = "https://v3.recurly.com"
redsys.base_url = "https://sis-t.redsys.es:25443/sis/realizarPago"
riskified.base_url = "https://sandbox.riskified.com/api"
shift4.base_url = "https://api.shift4.com/"

View File

@ -104,7 +104,7 @@ powertranz.base_url = "https://staging.ptranz.com/api/"
prophetpay.base_url = "https://ccm-thirdparty.cps.golf/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
razorpay.base_url = "https://sandbox.juspay.in/"
recurly.base_url = "https://{{merchant_subdomain_name}}.recurly.com"
recurly.base_url = "https://v3.recurly.com"
redsys.base_url = "https://sis-t.redsys.es:25443/sis/realizarPago"
shift4.base_url = "https://api.shift4.com/"
signifyd.base_url = "https://api.signifyd.com/"

View File

@ -108,7 +108,7 @@ powertranz.base_url = "https://staging.ptranz.com/api/"
prophetpay.base_url = "https://ccm-thirdparty.cps.golf/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
razorpay.base_url = "https://api.juspay.in"
recurly.base_url = "https://{{merchant_subdomain_name}}.recurly.com"
recurly.base_url = "https://v3.recurly.com"
redsys.base_url = "https://sis.redsys.es:25443/sis/realizarPago"
riskified.base_url = "https://wh.riskified.com/api/"
shift4.base_url = "https://api.shift4.com/"

View File

@ -108,7 +108,7 @@ powertranz.base_url = "https://staging.ptranz.com/api/"
prophetpay.base_url = "https://ccm-thirdparty.cps.golf/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
razorpay.base_url = "https://sandbox.juspay.in/"
recurly.base_url = "https://{{merchant_subdomain_name}}.recurly.com"
recurly.base_url = "https://v3.recurly.com"
redsys.base_url = "https://sis-t.redsys.es:25443/sis/realizarPago"
riskified.base_url = "https://sandbox.riskified.com/api"
shift4.base_url = "https://api.shift4.com/"

View File

@ -333,7 +333,7 @@ powertranz.base_url = "https://staging.ptranz.com/api/"
prophetpay.base_url = "https://ccm-thirdparty.cps.golf/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
razorpay.base_url = "https://sandbox.juspay.in/"
recurly.base_url = "https://{{merchant_subdomain_name}}.recurly.com"
recurly.base_url = "https://v3.recurly.com"
redsys.base_url = "https://sis-t.redsys.es:25443/sis/realizarPago"
riskified.base_url = "https://sandbox.riskified.com/api"
shift4.base_url = "https://api.shift4.com/"

View File

@ -191,7 +191,7 @@ powertranz.base_url = "https://staging.ptranz.com/api/"
prophetpay.base_url = "https://ccm-thirdparty.cps.golf/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
razorpay.base_url = "https://sandbox.juspay.in/"
recurly.base_url = "https://{{merchant_subdomain_name}}.recurly.com"
recurly.base_url = "https://v3.recurly.com"
redsys.base_url = "https://sis-t.redsys.es:25443/sis/realizarPago"
riskified.base_url = "https://sandbox.riskified.com/api"
shift4.base_url = "https://api.shift4.com/"

View File

@ -117,7 +117,7 @@ pub enum RoutableConnectors {
Prophetpay,
Rapyd,
Razorpay,
// Recurly,
Recurly,
// Redsys,
Riskified,
Shift4,
@ -263,7 +263,7 @@ pub enum Connector {
Prophetpay,
Rapyd,
Razorpay,
//Recurly,
Recurly,
// Redsys,
Shift4,
Square,
@ -415,7 +415,7 @@ impl Connector {
| Self::Powertranz
| Self::Prophetpay
| Self::Rapyd
// | Self::Recurly
| Self::Recurly
// | Self::Redsys
| Self::Shift4
| Self::Square
@ -552,7 +552,7 @@ impl From<RoutableConnectors> for Connector {
RoutableConnectors::Prophetpay => Self::Prophetpay,
RoutableConnectors::Rapyd => Self::Rapyd,
RoutableConnectors::Razorpay => Self::Razorpay,
// RoutableConnectors::Recurly => Self::Recurly,
RoutableConnectors::Recurly => Self::Recurly,
RoutableConnectors::Riskified => Self::Riskified,
RoutableConnectors::Shift4 => Self::Shift4,
RoutableConnectors::Signifyd => Self::Signifyd,

View File

@ -230,6 +230,7 @@ pub struct ConnectorConfig {
pub powertranz: Option<ConnectorTomlConfig>,
pub prophetpay: Option<ConnectorTomlConfig>,
pub razorpay: Option<ConnectorTomlConfig>,
pub recurly: Option<ConnectorTomlConfig>,
pub riskified: Option<ConnectorTomlConfig>,
pub rapyd: Option<ConnectorTomlConfig>,
pub shift4: Option<ConnectorTomlConfig>,
@ -400,6 +401,7 @@ impl ConnectorConfig {
Connector::Powertranz => Ok(connector_data.powertranz),
Connector::Razorpay => Ok(connector_data.razorpay),
Connector::Rapyd => Ok(connector_data.rapyd),
Connector::Recurly => Ok(connector_data.recurly),
Connector::Riskified => Ok(connector_data.riskified),
Connector::Shift4 => Ok(connector_data.shift4),
Connector::Signifyd => Ok(connector_data.signifyd),

View File

@ -1,5 +1,4 @@
pub mod transformers;
use common_utils::{
errors::CustomResult,
ext_traits::BytesExt,
@ -39,7 +38,10 @@ use hyperswitch_interfaces::{
use masking::{ExposeInterface, Mask};
use transformers as recurly;
use crate::{constants::headers, types::ResponseRouterData, utils};
use crate::{
connectors::recurly::transformers::RecurlyWebhookBody, constants::headers,
types::ResponseRouterData, utils,
};
#[derive(Clone)]
pub struct Recurly {
@ -52,6 +54,23 @@ impl Recurly {
amount_converter: &StringMinorUnitForConnector,
}
}
fn get_signature_elements_from_header(
headers: &actix_web::http::header::HeaderMap,
) -> CustomResult<Vec<Vec<u8>>, errors::ConnectorError> {
let security_header = headers
.get("recurly-signature")
.ok_or(errors::ConnectorError::WebhookSignatureNotFound)?;
let security_header_str = security_header
.to_str()
.change_context(errors::ConnectorError::WebhookSignatureNotFound)?;
let header_parts: Vec<Vec<u8>> = security_header_str
.split(',')
.map(|part| part.trim().as_bytes().to_vec())
.collect();
Ok(header_parts)
}
}
impl api::Payment for Recurly {}
@ -543,6 +562,58 @@ impl ConnectorIntegration<RSync, RefundsData, RefundsResponseData> for Recurly {
#[async_trait::async_trait]
impl webhooks::IncomingWebhook for Recurly {
fn get_webhook_source_verification_algorithm(
&self,
_request: &webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn common_utils::crypto::VerifySignature + Send>, errors::ConnectorError>
{
Ok(Box::new(common_utils::crypto::HmacSha256))
}
fn get_webhook_source_verification_signature(
&self,
request: &webhooks::IncomingWebhookRequestDetails<'_>,
_connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
// The `recurly-signature` header consists of a Unix timestamp (in milliseconds) followed by one or more HMAC-SHA256 signatures, separated by commas.
// Multiple signatures exist when a secret key is regenerated, with the old key remaining active for 24 hours.
let header_values = Self::get_signature_elements_from_header(request.headers)?;
let signature = header_values
.get(1)
.ok_or(errors::ConnectorError::WebhookSignatureNotFound)?;
hex::decode(signature).change_context(errors::ConnectorError::WebhookSignatureNotFound)
}
fn get_webhook_source_verification_message(
&self,
request: &webhooks::IncomingWebhookRequestDetails<'_>,
_merchant_id: &common_utils::id_type::MerchantId,
_connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets,
) -> CustomResult<Vec<u8>, errors::ConnectorError> {
let header_values = Self::get_signature_elements_from_header(request.headers)?;
let timestamp = header_values
.first()
.ok_or(errors::ConnectorError::WebhookSignatureNotFound)?;
Ok(format!(
"{}.{}",
String::from_utf8_lossy(timestamp),
String::from_utf8_lossy(request.body)
)
.into_bytes())
}
#[cfg(all(feature = "revenue_recovery", feature = "v2"))]
fn get_webhook_object_reference_id(
&self,
request: &webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
let webhook = RecurlyWebhookBody::get_webhook_object_from_body(request.body)
.change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?;
Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::ConnectorTransactionId(webhook.uuid),
))
}
#[cfg(any(feature = "v1", not(all(feature = "revenue_recovery", feature = "v2"))))]
fn get_webhook_object_reference_id(
&self,
_request: &webhooks::IncomingWebhookRequestDetails<'_>,
@ -550,6 +621,25 @@ impl webhooks::IncomingWebhook for Recurly {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
}
#[cfg(all(feature = "revenue_recovery", feature = "v2"))]
fn get_webhook_event_type(
&self,
request: &webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::IncomingWebhookEvent, errors::ConnectorError> {
let webhook = RecurlyWebhookBody::get_webhook_object_from_body(request.body)
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
let event = match webhook.event_type {
transformers::RecurlyPaymentEventType::PaymentSucceeded => {
api_models::webhooks::IncomingWebhookEvent::RecoveryPaymentSuccess
}
transformers::RecurlyPaymentEventType::PaymentFailed => {
api_models::webhooks::IncomingWebhookEvent::RecoveryPaymentFailure
}
};
Ok(event)
}
#[cfg(any(feature = "v1", not(all(feature = "revenue_recovery", feature = "v2"))))]
fn get_webhook_event_type(
&self,
_request: &webhooks::IncomingWebhookRequestDetails<'_>,
@ -559,9 +649,11 @@ impl webhooks::IncomingWebhook for Recurly {
fn get_webhook_resource_object(
&self,
_request: &webhooks::IncomingWebhookRequestDetails<'_>,
request: &webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn masking::ErasedMaskSerialize>, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented))
let webhook = RecurlyWebhookBody::get_webhook_object_from_body(request.body)
.change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?;
Ok(Box::new(webhook))
}
}

View File

@ -1,5 +1,6 @@
use common_enums::enums;
use common_utils::types::StringMinorUnit;
use common_utils::{errors::CustomResult, ext_traits::ByteSliceExt, types::StringMinorUnit};
use error_stack::ResultExt;
use hyperswitch_domain_models::{
payment_method_data::PaymentMethodData,
router_data::{ConnectorAuthType, RouterData},
@ -226,3 +227,27 @@ pub struct RecurlyErrorResponse {
pub message: String,
pub reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RecurlyWebhookBody {
// transaction id
pub uuid: String,
pub event_type: RecurlyPaymentEventType,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum RecurlyPaymentEventType {
#[serde(rename = "succeeded")]
PaymentSucceeded,
#[serde(rename = "failed")]
PaymentFailed,
}
impl RecurlyWebhookBody {
pub fn get_webhook_object_from_body(body: &[u8]) -> CustomResult<Self, errors::ConnectorError> {
let webhook_body = body
.parse_struct::<Self>("RecurlyWebhookBody")
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
Ok(webhook_body)
}
}

View File

@ -14,7 +14,7 @@ use diesel_models::configs;
#[cfg(all(any(feature = "v1", feature = "v2"), feature = "olap"))]
use diesel_models::{business_profile::CardTestingGuardConfig, organization::OrganizationBridge};
use error_stack::{report, FutureExt, ResultExt};
use hyperswitch_connectors::connectors::chargebee;
use hyperswitch_connectors::connectors::{chargebee, recurly};
use hyperswitch_domain_models::merchant_connector_account::{
FromRequestEncryptableMerchantConnectorAccount, UpdateEncryptableMerchantConnectorAccount,
};
@ -1535,6 +1535,10 @@ impl ConnectorAuthTypeAndMetadataValidation<'_> {
razorpay::transformers::RazorpayAuthType::try_from(self.auth_type)?;
Ok(())
}
api_enums::Connector::Recurly => {
recurly::transformers::RecurlyAuthType::try_from(self.auth_type)?;
Ok(())
}
api_enums::Connector::Shift4 => {
shift4::transformers::Shift4AuthType::try_from(self.auth_type)?;
Ok(())

View File

@ -526,7 +526,9 @@ impl ConnectorData {
enums::Connector::Rapyd => {
Ok(ConnectorEnum::Old(Box::new(connector::Rapyd::new())))
}
// enums::Connector::Recurly => Ok(ConnectorEnum::Old(Box::new(connector::Recurly))),
enums::Connector::Recurly => {
Ok(ConnectorEnum::Old(Box::new(connector::Recurly::new())))
}
// enums::Connector::Redsys => Ok(ConnectorEnum::Old(Box::new(connector::Redsys))),
enums::Connector::Shift4 => {
Ok(ConnectorEnum::Old(Box::new(connector::Shift4::new())))

View File

@ -296,7 +296,7 @@ impl ForeignTryFrom<api_enums::Connector> for common_enums::RoutableConnectors {
api_enums::Connector::Prophetpay => Self::Prophetpay,
api_enums::Connector::Rapyd => Self::Rapyd,
api_enums::Connector::Razorpay => Self::Razorpay,
// api_enums::Connector::Recurly => Self::Recurly,
api_enums::Connector::Recurly => Self::Recurly,
// api_enums::Connector::Redsys => Self::Redsys,
api_enums::Connector::Shift4 => Self::Shift4,
api_enums::Connector::Signifyd => {

View File

@ -157,7 +157,7 @@ powertranz.base_url = "https://staging.ptranz.com/api/"
prophetpay.base_url = "https://ccm-thirdparty.cps.golf/"
rapyd.base_url = "https://sandboxapi.rapyd.net"
razorpay.base_url = "https://sandbox.juspay.in/"
recurly.base_url = "https://{{merchant_subdomain_name}}.recurly.com"
recurly.base_url = "https://v3.recurly.com"
redsys.base_url = "https://sis-t.redsys.es:25443/sis/realizarPago"
riskified.base_url = "https://sandbox.riskified.com/api"
shift4.base_url = "https://api.shift4.com/"