mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-28 04:04:55 +08:00
feat(compatibility): add support for stripe compatible webhooks (#1728)
This commit is contained in:
committed by
GitHub
parent
14c2d72509
commit
87ae99f7f2
@ -98,9 +98,3 @@ pub enum OutgoingWebhookContent {
|
||||
RefundDetails(refunds::RefundResponse),
|
||||
DisputeDetails(Box<disputes::DisputeResponse>),
|
||||
}
|
||||
|
||||
pub trait OutgoingWebhookType:
|
||||
Serialize + From<OutgoingWebhook> + Sync + Send + std::fmt::Debug
|
||||
{
|
||||
}
|
||||
impl OutgoingWebhookType for OutgoingWebhook {}
|
||||
|
||||
@ -2,21 +2,70 @@ use api_models::{
|
||||
enums::DisputeStatus,
|
||||
webhooks::{self as api},
|
||||
};
|
||||
use common_utils::{crypto::SignMessage, date_time, ext_traits};
|
||||
use error_stack::{IntoReport, ResultExt};
|
||||
use router_env::logger;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{
|
||||
payment_intents::types::StripePaymentIntentResponse, refunds::types::StripeRefundResponse,
|
||||
};
|
||||
use crate::{
|
||||
core::{errors, webhooks::types::OutgoingWebhookType},
|
||||
headers,
|
||||
services::request::Maskable,
|
||||
};
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct StripeOutgoingWebhook {
|
||||
id: Option<String>,
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
stype: &'static str,
|
||||
object: &'static str,
|
||||
data: StripeWebhookObject,
|
||||
created: u64,
|
||||
// api_version: "2019-11-05", // not used
|
||||
}
|
||||
|
||||
impl api::OutgoingWebhookType for StripeOutgoingWebhook {}
|
||||
impl OutgoingWebhookType for StripeOutgoingWebhook {
|
||||
fn get_outgoing_webhooks_signature(
|
||||
&self,
|
||||
payment_response_hash_key: Option<String>,
|
||||
) -> errors::CustomResult<Option<String>, errors::WebhooksFlowError> {
|
||||
let timestamp = self.created;
|
||||
|
||||
let payment_response_hash_key = payment_response_hash_key
|
||||
.ok_or(errors::WebhooksFlowError::MerchantConfigNotFound)
|
||||
.into_report()
|
||||
.attach_printable("For stripe compatibility payment_response_hash_key is mandatory")?;
|
||||
|
||||
let webhook_signature_payload =
|
||||
ext_traits::Encode::<serde_json::Value>::encode_to_string_of_json(self)
|
||||
.change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed)
|
||||
.attach_printable("failed encoding outgoing webhook payload")?;
|
||||
|
||||
let new_signature_payload = format!("{timestamp}.{webhook_signature_payload}");
|
||||
let v1 = hex::encode(
|
||||
common_utils::crypto::HmacSha256::sign_message(
|
||||
&common_utils::crypto::HmacSha256,
|
||||
payment_response_hash_key.as_bytes(),
|
||||
new_signature_payload.as_bytes(),
|
||||
)
|
||||
.change_context(errors::WebhooksFlowError::OutgoingWebhookSigningFailed)
|
||||
.attach_printable("Failed to sign the message")?,
|
||||
);
|
||||
|
||||
let t = timestamp;
|
||||
Ok(Some(format!("t={t},v1={v1}")))
|
||||
}
|
||||
|
||||
fn add_webhook_header(header: &mut Vec<(String, Maskable<String>)>, signature: String) {
|
||||
header.push((
|
||||
headers::STRIPE_COMPATIBLE_WEBHOOK_SIGNATURE.to_string(),
|
||||
signature.into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "type", content = "object", rename_all = "snake_case")]
|
||||
@ -76,13 +125,46 @@ impl From<DisputeStatus> for StripeDisputeStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_stripe_event_type(event_type: api_models::enums::EventType) -> &'static str {
|
||||
match event_type {
|
||||
api_models::enums::EventType::PaymentSucceeded => "payment_intent.succeeded",
|
||||
api_models::enums::EventType::PaymentFailed => "payment_intent.payment_failed",
|
||||
api_models::enums::EventType::PaymentProcessing => "payment_intent.processing",
|
||||
|
||||
// the below are not really stripe compatible because stripe doesn't provide this
|
||||
api_models::enums::EventType::ActionRequired => "action.required",
|
||||
api_models::enums::EventType::RefundSucceeded => "refund.succeeded",
|
||||
api_models::enums::EventType::RefundFailed => "refund.failed",
|
||||
api_models::enums::EventType::DisputeOpened => "dispute.failed",
|
||||
api_models::enums::EventType::DisputeExpired => "dispute.expired",
|
||||
api_models::enums::EventType::DisputeAccepted => "dispute.accepted",
|
||||
api_models::enums::EventType::DisputeCancelled => "dispute.cancelled",
|
||||
api_models::enums::EventType::DisputeChallenged => "dispute.challenged",
|
||||
api_models::enums::EventType::DisputeWon => "dispute.won",
|
||||
api_models::enums::EventType::DisputeLost => "dispute.lost",
|
||||
}
|
||||
}
|
||||
|
||||
impl From<api::OutgoingWebhook> for StripeOutgoingWebhook {
|
||||
fn from(value: api::OutgoingWebhook) -> Self {
|
||||
let data: StripeWebhookObject = value.content.into();
|
||||
Self {
|
||||
id: data.get_id(),
|
||||
stype: "webhook_endpoint",
|
||||
data,
|
||||
id: value.event_id,
|
||||
stype: get_stripe_event_type(value.event_type),
|
||||
data: StripeWebhookObject::from(value.content),
|
||||
object: "event",
|
||||
// put this conversion it into a function
|
||||
created: u64::try_from(value.timestamp.assume_utc().unix_timestamp()).unwrap_or_else(
|
||||
|error| {
|
||||
logger::error!(
|
||||
%error,
|
||||
"incorrect value for `webhook.timestamp` provided {}", value.timestamp
|
||||
);
|
||||
// Current timestamp converted to Unix timestamp should have a positive value
|
||||
// for many years to come
|
||||
u64::try_from(date_time::now().assume_utc().unix_timestamp())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -100,13 +182,3 @@ impl From<api::OutgoingWebhookContent> for StripeWebhookObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StripeWebhookObject {
|
||||
fn get_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::PaymentIntent(p) => p.id.to_owned(),
|
||||
Self::Refund(r) => Some(r.id.to_owned()),
|
||||
Self::Dispute(d) => Some(d.id.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
pub mod transformers;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
use common_utils::{crypto::SignMessage, ext_traits};
|
||||
use error_stack::{report, IntoReport, ResultExt};
|
||||
use masking::ExposeInterface;
|
||||
use router_env::{instrument, tracing};
|
||||
@ -14,11 +13,11 @@ use crate::{
|
||||
errors::{self, CustomResult, RouterResponse},
|
||||
payments, refunds,
|
||||
},
|
||||
headers, logger,
|
||||
logger,
|
||||
routes::AppState,
|
||||
services,
|
||||
types::{
|
||||
self, api, domain,
|
||||
self as router_types, api, domain,
|
||||
storage::{self, enums},
|
||||
transformers::{ForeignInto, ForeignTryInto},
|
||||
},
|
||||
@ -29,7 +28,7 @@ const OUTGOING_WEBHOOK_TIMEOUT_SECS: u64 = 5;
|
||||
const MERCHANT_ID: &str = "merchant_id";
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn payments_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
||||
pub async fn payments_incoming_webhook_flow<W: types::OutgoingWebhookType>(
|
||||
state: AppState,
|
||||
merchant_account: domain::MerchantAccount,
|
||||
key_store: domain::MerchantKeyStore,
|
||||
@ -108,7 +107,7 @@ pub async fn payments_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn refunds_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
||||
pub async fn refunds_incoming_webhook_flow<W: types::OutgoingWebhookType>(
|
||||
state: AppState,
|
||||
merchant_account: domain::MerchantAccount,
|
||||
key_store: domain::MerchantKeyStore,
|
||||
@ -325,7 +324,7 @@ pub async fn get_or_update_dispute_object(
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn disputes_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
||||
pub async fn disputes_incoming_webhook_flow<W: types::OutgoingWebhookType>(
|
||||
state: AppState,
|
||||
merchant_account: domain::MerchantAccount,
|
||||
webhook_details: api::IncomingWebhookDetails,
|
||||
@ -388,7 +387,7 @@ pub async fn disputes_incoming_webhook_flow<W: api::OutgoingWebhookType>(
|
||||
}
|
||||
}
|
||||
|
||||
async fn bank_transfer_webhook_flow<W: api::OutgoingWebhookType>(
|
||||
async fn bank_transfer_webhook_flow<W: types::OutgoingWebhookType>(
|
||||
state: AppState,
|
||||
merchant_account: domain::MerchantAccount,
|
||||
key_store: domain::MerchantKeyStore,
|
||||
@ -465,7 +464,7 @@ async fn bank_transfer_webhook_flow<W: api::OutgoingWebhookType>(
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(skip_all)]
|
||||
pub async fn create_event_and_trigger_outgoing_webhook<W: api::OutgoingWebhookType>(
|
||||
pub async fn create_event_and_trigger_outgoing_webhook<W: types::OutgoingWebhookType>(
|
||||
state: AppState,
|
||||
merchant_account: domain::MerchantAccount,
|
||||
event_type: enums::EventType,
|
||||
@ -506,34 +505,9 @@ pub async fn create_event_and_trigger_outgoing_webhook<W: api::OutgoingWebhookTy
|
||||
timestamp: event.created_at,
|
||||
};
|
||||
|
||||
let webhook_signature_payload =
|
||||
ext_traits::Encode::<serde_json::Value>::encode_to_string_of_json(&outgoing_webhook)
|
||||
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
|
||||
.attach_printable("failed encoding outgoing webhook payload")?;
|
||||
|
||||
let outgoing_webhooks_signature = merchant_account
|
||||
.payment_response_hash_key
|
||||
.clone()
|
||||
.map(|key| {
|
||||
common_utils::crypto::HmacSha512::sign_message(
|
||||
&common_utils::crypto::HmacSha512,
|
||||
key.as_bytes(),
|
||||
webhook_signature_payload.as_bytes(),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
|
||||
.attach_printable("Failed to sign the message")?
|
||||
.map(hex::encode);
|
||||
|
||||
arbiter.spawn(async move {
|
||||
let result = trigger_webhook_to_merchant::<W>(
|
||||
merchant_account,
|
||||
outgoing_webhook,
|
||||
outgoing_webhooks_signature,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
trigger_webhook_to_merchant::<W>(merchant_account, outgoing_webhook, &state).await;
|
||||
|
||||
if let Err(e) = result {
|
||||
logger::error!(?e);
|
||||
@ -544,10 +518,9 @@ pub async fn create_event_and_trigger_outgoing_webhook<W: api::OutgoingWebhookTy
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
||||
pub async fn trigger_webhook_to_merchant<W: types::OutgoingWebhookType>(
|
||||
merchant_account: domain::MerchantAccount,
|
||||
webhook: api::OutgoingWebhook,
|
||||
outgoing_webhooks_signature: Option<String>,
|
||||
state: &AppState,
|
||||
) -> CustomResult<(), errors::WebhooksFlowError> {
|
||||
let webhook_details_json = merchant_account
|
||||
@ -570,7 +543,10 @@ pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
||||
|
||||
let transformed_outgoing_webhook = W::from(webhook);
|
||||
|
||||
let transformed_outgoing_webhook_string = types::RequestBody::log_and_get_request_body(
|
||||
let outgoing_webhooks_signature = transformed_outgoing_webhook
|
||||
.get_outgoing_webhooks_signature(merchant_account.payment_response_hash_key.clone())?;
|
||||
|
||||
let transformed_outgoing_webhook_string = router_types::RequestBody::log_and_get_request_body(
|
||||
&transformed_outgoing_webhook,
|
||||
Encode::<serde_json::Value>::encode_to_string_of_json,
|
||||
)
|
||||
@ -583,7 +559,7 @@ pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
||||
)];
|
||||
|
||||
if let Some(signature) = outgoing_webhooks_signature {
|
||||
header.push((headers::X_WEBHOOK_SIGNATURE.to_string(), signature.into()))
|
||||
W::add_webhook_header(&mut header, signature)
|
||||
}
|
||||
|
||||
let request = services::RequestBuilder::new()
|
||||
@ -649,7 +625,7 @@ pub async fn trigger_webhook_to_merchant<W: api::OutgoingWebhookType>(
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn webhooks_core<W: api::OutgoingWebhookType>(
|
||||
pub async fn webhooks_core<W: types::OutgoingWebhookType>(
|
||||
state: &AppState,
|
||||
req: &actix_web::HttpRequest,
|
||||
merchant_account: domain::MerchantAccount,
|
||||
|
||||
@ -1 +0,0 @@
|
||||
|
||||
45
crates/router/src/core/webhooks/types.rs
Normal file
45
crates/router/src/core/webhooks/types.rs
Normal file
@ -0,0 +1,45 @@
|
||||
use api_models::webhooks;
|
||||
use common_utils::{crypto::SignMessage, ext_traits};
|
||||
use error_stack::ResultExt;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{core::errors, headers, services::request::Maskable};
|
||||
|
||||
pub trait OutgoingWebhookType:
|
||||
Serialize + From<webhooks::OutgoingWebhook> + Sync + Send + std::fmt::Debug
|
||||
{
|
||||
fn get_outgoing_webhooks_signature(
|
||||
&self,
|
||||
payment_response_hash_key: Option<String>,
|
||||
) -> errors::CustomResult<Option<String>, errors::WebhooksFlowError>;
|
||||
|
||||
fn add_webhook_header(header: &mut Vec<(String, Maskable<String>)>, signature: String);
|
||||
}
|
||||
|
||||
impl OutgoingWebhookType for webhooks::OutgoingWebhook {
|
||||
fn get_outgoing_webhooks_signature(
|
||||
&self,
|
||||
payment_response_hash_key: Option<String>,
|
||||
) -> errors::CustomResult<Option<String>, errors::WebhooksFlowError> {
|
||||
let webhook_signature_payload =
|
||||
ext_traits::Encode::<serde_json::Value>::encode_to_string_of_json(self)
|
||||
.change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed)
|
||||
.attach_printable("failed encoding outgoing webhook payload")?;
|
||||
|
||||
Ok(payment_response_hash_key
|
||||
.map(|key| {
|
||||
common_utils::crypto::HmacSha512::sign_message(
|
||||
&common_utils::crypto::HmacSha512,
|
||||
key.as_bytes(),
|
||||
webhook_signature_payload.as_bytes(),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
.change_context(errors::WebhooksFlowError::OutgoingWebhookSigningFailed)
|
||||
.attach_printable("Failed to sign the message")?
|
||||
.map(hex::encode))
|
||||
}
|
||||
fn add_webhook_header(header: &mut Vec<(String, Maskable<String>)>, signature: String) {
|
||||
header.push((headers::X_WEBHOOK_SIGNATURE.to_string(), signature.into()))
|
||||
}
|
||||
}
|
||||
@ -65,6 +65,8 @@ pub mod headers {
|
||||
pub const X_ACCEPT_VERSION: &str = "X-Accept-Version";
|
||||
pub const X_DATE: &str = "X-Date";
|
||||
pub const X_WEBHOOK_SIGNATURE: &str = "X-Webhook-Signature-512";
|
||||
|
||||
pub const STRIPE_COMPATIBLE_WEBHOOK_SIGNATURE: &str = "Stripe-Signature";
|
||||
}
|
||||
|
||||
pub mod pii {
|
||||
|
||||
@ -3,13 +3,12 @@ use router_env::{instrument, tracing, Flow};
|
||||
|
||||
use super::app::AppState;
|
||||
use crate::{
|
||||
core::webhooks,
|
||||
core::webhooks::{self, types},
|
||||
services::{api, authentication as auth},
|
||||
types::api as api_types,
|
||||
};
|
||||
|
||||
#[instrument(skip_all, fields(flow = ?Flow::IncomingWebhookReceive))]
|
||||
pub async fn receive_incoming_webhook<W: api_types::OutgoingWebhookType>(
|
||||
pub async fn receive_incoming_webhook<W: types::OutgoingWebhookType>(
|
||||
state: web::Data<AppState>,
|
||||
req: HttpRequest,
|
||||
body: web::Bytes,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use api_models::admin::MerchantConnectorWebhookDetails;
|
||||
pub use api_models::webhooks::{
|
||||
IncomingWebhookDetails, IncomingWebhookEvent, MerchantWebhookConfig, ObjectReferenceId,
|
||||
OutgoingWebhook, OutgoingWebhookContent, OutgoingWebhookType, WebhookFlow,
|
||||
OutgoingWebhook, OutgoingWebhookContent, WebhookFlow,
|
||||
};
|
||||
use common_utils::ext_traits::ValueExt;
|
||||
use error_stack::{IntoReport, ResultExt};
|
||||
|
||||
Reference in New Issue
Block a user