Skip to content

Commit

Permalink
feat(compatibility): add support for stripe compatible webhooks (#1728)
Browse files Browse the repository at this point in the history
  • Loading branch information
Abhicodes-crypto authored Jul 17, 2023
1 parent 14c2d72 commit 87ae99f
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 68 deletions.
6 changes: 0 additions & 6 deletions crates/api_models/src/webhooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
104 changes: 88 additions & 16 deletions crates/router/src/compatibility/stripe/webhooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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()
},
),
}
}
}
Expand All @@ -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()),
}
}
}
58 changes: 17 additions & 41 deletions crates/router/src/core/webhooks.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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},
},
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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,
)
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion crates/router/src/core/webhooks/transformers.rs

This file was deleted.

45 changes: 45 additions & 0 deletions crates/router/src/core/webhooks/types.rs
Original file line number Diff line number Diff line change
@@ -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()))
}
}
2 changes: 2 additions & 0 deletions crates/router/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 2 additions & 3 deletions crates/router/src/routes/webhooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/router/src/types/api/webhooks.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down

0 comments on commit 87ae99f

Please sign in to comment.