feat(webhooks): add support for custom outgoing webhook http headers (#5275)

Co-authored-by: Chikke Srujan <chikke.srujan@Chikke-Srujan-N7WRTY72X7.local>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
chikke srujan
2024-07-16 21:43:44 +05:30
committed by GitHub
parent 29f8732d30
commit 101b21f52d
14 changed files with 365 additions and 224 deletions

View File

@ -7006,6 +7006,11 @@
}
],
"nullable": true
},
"outgoing_webhook_custom_http_headers": {
"type": "object",
"description": "These key-value pairs are sent as additional custom headers in the outgoing webhook request. It is recommended not to use more than four key-value pairs.",
"nullable": true
}
},
"additionalProperties": false
@ -7164,6 +7169,11 @@
}
],
"nullable": true
},
"outgoing_webhook_custom_http_headers": {
"type": "object",
"description": "These key-value pairs are sent as additional custom headers in the outgoing webhook request.",
"nullable": true
}
}
},

View File

@ -1180,6 +1180,10 @@ pub struct BusinessProfileCreate {
/// Default payout link config
#[schema(value_type = Option<BusinessPayoutLinkConfig>)]
pub payout_link_config: Option<BusinessPayoutLinkConfig>,
/// These key-value pairs are sent as additional custom headers in the outgoing webhook request. It is recommended not to use more than four key-value pairs.
#[schema(value_type = Option<Object>, example = r#"{ "key1": "value-1", "key2": "value-2" }"#)]
pub outgoing_webhook_custom_http_headers: Option<HashMap<String, String>>,
}
#[derive(Clone, Debug, ToSchema, Serialize)]
@ -1272,6 +1276,10 @@ pub struct BusinessProfileResponse {
/// Default payout link config
#[schema(value_type = Option<BusinessPayoutLinkConfig>)]
pub payout_link_config: Option<BusinessPayoutLinkConfig>,
/// These key-value pairs are sent as additional custom headers in the outgoing webhook request.
#[schema(value_type = Option<Object>, example = r#"{ "key1": "value-1", "key2": "value-2" }"#)]
pub outgoing_webhook_custom_http_headers: Option<HashMap<String, String>>,
}
#[derive(Clone, Debug, Deserialize, ToSchema, Serialize)]
@ -1356,6 +1364,10 @@ pub struct BusinessProfileUpdate {
/// Default payout link config
#[schema(value_type = Option<BusinessPayoutLinkConfig>)]
pub payout_link_config: Option<BusinessPayoutLinkConfig>,
/// These key-value pairs are sent as additional custom headers in the outgoing webhook request. It is recommended not to use more than four key-value pairs.
#[schema(value_type = Option<Object>, example = r#"{ "key1": "value-1", "key2": "value-2" }"#)]
pub outgoing_webhook_custom_http_headers: Option<HashMap<String, String>>,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, ToSchema)]
pub struct BusinessCollectLinkConfig {

View File

@ -1,7 +1,7 @@
use common_utils::pii;
use diesel::{AsChangeset, Identifiable, Insertable, Queryable, Selectable};
use crate::schema::business_profile;
use crate::{encryption::Encryption, schema::business_profile};
#[derive(
Clone,
@ -43,6 +43,7 @@ pub struct BusinessProfile {
pub use_billing_as_payment_method_billing: Option<bool>,
pub collect_shipping_details_from_wallet_connector: Option<bool>,
pub collect_billing_details_from_wallet_connector: Option<bool>,
pub outgoing_webhook_custom_http_headers: Option<Encryption>,
}
#[derive(Clone, Debug, Insertable, router_derive::DebugAsDisplay)]
@ -76,6 +77,7 @@ pub struct BusinessProfileNew {
pub use_billing_as_payment_method_billing: Option<bool>,
pub collect_shipping_details_from_wallet_connector: Option<bool>,
pub collect_billing_details_from_wallet_connector: Option<bool>,
pub outgoing_webhook_custom_http_headers: Option<Encryption>,
}
#[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)]
@ -106,6 +108,7 @@ pub struct BusinessProfileUpdateInternal {
pub use_billing_as_payment_method_billing: Option<bool>,
pub collect_shipping_details_from_wallet_connector: Option<bool>,
pub collect_billing_details_from_wallet_connector: Option<bool>,
pub outgoing_webhook_custom_http_headers: Option<Encryption>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -134,6 +137,7 @@ pub enum BusinessProfileUpdate {
collect_shipping_details_from_wallet_connector: Option<bool>,
collect_billing_details_from_wallet_connector: Option<bool>,
is_connector_agnostic_mit_enabled: Option<bool>,
outgoing_webhook_custom_http_headers: Option<Encryption>,
},
ExtendedCardInfoUpdate {
is_extended_card_info_enabled: Option<bool>,
@ -170,6 +174,7 @@ impl From<BusinessProfileUpdate> for BusinessProfileUpdateInternal {
collect_shipping_details_from_wallet_connector,
collect_billing_details_from_wallet_connector,
is_connector_agnostic_mit_enabled,
outgoing_webhook_custom_http_headers,
} => Self {
profile_name,
modified_at,
@ -194,6 +199,7 @@ impl From<BusinessProfileUpdate> for BusinessProfileUpdateInternal {
collect_shipping_details_from_wallet_connector,
collect_billing_details_from_wallet_connector,
is_connector_agnostic_mit_enabled,
outgoing_webhook_custom_http_headers,
..Default::default()
},
BusinessProfileUpdate::ExtendedCardInfoUpdate {
@ -244,6 +250,7 @@ impl From<BusinessProfileNew> for BusinessProfile {
.collect_shipping_details_from_wallet_connector,
collect_billing_details_from_wallet_connector: new
.collect_billing_details_from_wallet_connector,
outgoing_webhook_custom_http_headers: new.outgoing_webhook_custom_http_headers,
}
}
}
@ -275,6 +282,7 @@ impl BusinessProfileUpdate {
use_billing_as_payment_method_billing,
collect_shipping_details_from_wallet_connector,
collect_billing_details_from_wallet_connector,
outgoing_webhook_custom_http_headers,
} = self.into();
BusinessProfile {
profile_name: profile_name.unwrap_or(source.profile_name),
@ -303,6 +311,7 @@ impl BusinessProfileUpdate {
use_billing_as_payment_method_billing,
collect_shipping_details_from_wallet_connector,
collect_billing_details_from_wallet_connector,
outgoing_webhook_custom_http_headers,
..source
}
}

View File

@ -204,6 +204,7 @@ diesel::table! {
use_billing_as_payment_method_billing -> Nullable<Bool>,
collect_shipping_details_from_wallet_connector -> Nullable<Bool>,
collect_billing_details_from_wallet_connector -> Nullable<Bool>,
outgoing_webhook_custom_http_headers -> Nullable<Bytea>,
}
}

View File

@ -204,6 +204,7 @@ diesel::table! {
use_billing_as_payment_method_billing -> Nullable<Bool>,
collect_shipping_details_from_wallet_connector -> Nullable<Bool>,
collect_billing_details_from_wallet_connector -> Nullable<Bool>,
outgoing_webhook_custom_http_headers -> Nullable<Bytea>,
}
}

View File

@ -25,7 +25,7 @@ use crate::{
core::{
encryption::transfer_encryption_key,
errors::{self, RouterResponse, RouterResult, StorageErrorExt},
payment_methods::{cards, transformers},
payment_methods::{cards, cards::create_encrypted_data, transformers},
payments::helpers,
pm_auth::helpers::PaymentAuthConnectorDataExt,
routing::helpers as routing_helpers,
@ -35,7 +35,8 @@ use crate::{
routes::{metrics, SessionState},
services::{self, api as service_api, authentication, pm_auth as payment_initiation_service},
types::{
self, api,
self,
api::{self, admin},
domain::{
self,
types::{self as domain_types, AsyncLift},
@ -239,7 +240,7 @@ impl MerchantAccountCreateBridge for api::MerchantAccountCreate {
.create_or_validate(db)
.await?;
let key = key_store.key.into_inner();
let key = key_store.key.clone().into_inner();
let mut merchant_account = async {
Ok::<_, error_stack::Report<common_utils::errors::CryptoError>>(
@ -292,7 +293,7 @@ impl MerchantAccountCreateBridge for api::MerchantAccountCreate {
.change_context(errors::ApiErrorResponse::InternalServerError)?;
CreateBusinessProfile::new(self.primary_business_details.clone())
.create_business_profiles(db, &mut merchant_account)
.create_business_profiles(db, &mut merchant_account, &key_store)
.await?;
Ok(merchant_account)
@ -386,6 +387,7 @@ impl CreateBusinessProfile {
&self,
db: &dyn StorageInterface,
merchant_account: &mut domain::MerchantAccount,
key_store: &domain::MerchantKeyStore,
) -> RouterResult<()> {
match self {
Self::CreateFromPrimaryBusinessDetails {
@ -395,6 +397,7 @@ impl CreateBusinessProfile {
db,
merchant_account.clone(),
primary_business_details,
key_store,
)
.await?;
@ -407,7 +410,7 @@ impl CreateBusinessProfile {
}
Self::CreateDefaultBusinessProfile => {
let business_profile = self
.create_default_business_profile(db, merchant_account.clone())
.create_default_business_profile(db, merchant_account.clone(), key_store)
.await?;
merchant_account.default_profile = Some(business_profile.profile_id);
@ -422,11 +425,13 @@ impl CreateBusinessProfile {
&self,
db: &dyn StorageInterface,
merchant_account: domain::MerchantAccount,
key_store: &domain::MerchantKeyStore,
) -> RouterResult<diesel_models::business_profile::BusinessProfile> {
let business_profile = create_and_insert_business_profile(
db,
api_models::admin::BusinessProfileCreate::default(),
merchant_account.clone(),
key_store,
)
.await?;
@ -440,6 +445,7 @@ impl CreateBusinessProfile {
db: &dyn StorageInterface,
merchant_account: domain::MerchantAccount,
primary_business_details: &Vec<admin_types::PrimaryBusinessDetails>,
key_store: &domain::MerchantKeyStore,
) -> RouterResult<Vec<diesel_models::business_profile::BusinessProfile>> {
let mut business_profiles_vector = Vec::with_capacity(primary_business_details.len());
@ -459,6 +465,7 @@ impl CreateBusinessProfile {
db,
business_profile_create_request,
merchant_account.clone(),
key_store,
)
.await
.map_err(|business_profile_insert_error| {
@ -653,6 +660,7 @@ pub async fn create_business_profile_from_business_labels(
db,
business_profile_create_request,
merchant_account.clone(),
key_store,
)
.await
.map_err(|business_profile_insert_error| {
@ -723,6 +731,7 @@ pub async fn update_business_profile_cascade(
collect_shipping_details_from_wallet_connector: None,
collect_billing_details_from_wallet_connector: None,
is_connector_agnostic_mit_enabled: None,
outgoing_webhook_custom_http_headers: None,
};
let update_futures = business_profiles.iter().map(|business_profile| async {
@ -1823,11 +1832,10 @@ pub async fn create_and_insert_business_profile(
db: &dyn StorageInterface,
request: api::BusinessProfileCreate,
merchant_account: domain::MerchantAccount,
key_store: &domain::MerchantKeyStore,
) -> RouterResult<storage::business_profile::BusinessProfile> {
let business_profile_new = storage::business_profile::BusinessProfileNew::foreign_try_from((
merchant_account,
request,
))?;
let business_profile_new =
admin::create_business_profile(merchant_account, request, key_store).await?;
let profile_name = business_profile_new.profile_name.clone();
@ -1878,7 +1886,8 @@ pub async fn create_business_profile(
}
let business_profile =
create_and_insert_business_profile(db, request, merchant_account.clone()).await?;
create_and_insert_business_profile(db, request, merchant_account.clone(), &key_store)
.await?;
if merchant_account.default_profile.is_some() {
let unset_default_profile = domain::MerchantAccountUpdate::UnsetDefaultProfile;
@ -1888,8 +1897,10 @@ pub async fn create_business_profile(
}
Ok(service_api::ApplicationResponse::Json(
api_models::admin::BusinessProfileResponse::foreign_try_from(business_profile)
.change_context(errors::ApiErrorResponse::InternalServerError)?,
admin::business_profile_response(business_profile, &key_store)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to parse business profile details")
.await?,
))
}
@ -1898,17 +1909,23 @@ pub async fn list_business_profile(
merchant_id: String,
) -> RouterResponse<Vec<api_models::admin::BusinessProfileResponse>> {
let db = state.store.as_ref();
let business_profiles = db
let key_store = db
.get_merchant_key_store_by_merchant_id(&merchant_id, &db.get_master_key().to_vec().into())
.await
.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?;
let profiles = db
.list_business_profile_by_merchant_id(&merchant_id)
.await
.to_not_found_response(errors::ApiErrorResponse::InternalServerError)?
.into_iter()
.map(|business_profile| {
api_models::admin::BusinessProfileResponse::foreign_try_from(business_profile)
})
.collect::<Result<Vec<_>, _>>()
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to parse business profile details")?;
.clone();
let mut business_profiles = Vec::new();
for profile in profiles {
let business_profile = admin::business_profile_response(profile, &key_store)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to parse business profile details")?;
business_profiles.push(business_profile);
}
Ok(service_api::ApplicationResponse::Json(business_profiles))
}
@ -1916,8 +1933,13 @@ pub async fn list_business_profile(
pub async fn retrieve_business_profile(
state: SessionState,
profile_id: String,
merchant_id: String,
) -> RouterResponse<api_models::admin::BusinessProfileResponse> {
let db = state.store.as_ref();
let key_store = db
.get_merchant_key_store_by_merchant_id(&merchant_id, &db.get_master_key().to_vec().into())
.await
.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?;
let business_profile = db
.find_business_profile_by_profile_id(&profile_id)
.await
@ -1926,8 +1948,10 @@ pub async fn retrieve_business_profile(
})?;
Ok(service_api::ApplicationResponse::Json(
api_models::admin::BusinessProfileResponse::foreign_try_from(business_profile)
.change_context(errors::ApiErrorResponse::InternalServerError)?,
admin::business_profile_response(business_profile, &key_store)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to parse business profile details")
.await?,
))
}
@ -1960,6 +1984,14 @@ pub async fn update_business_profile(
.to_not_found_response(errors::ApiErrorResponse::BusinessProfileNotFound {
id: profile_id.to_owned(),
})?;
let key_store = db
.get_merchant_key_store_by_merchant_id(
merchant_id,
&state.store.get_master_key().to_vec().into(),
)
.await
.to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)
.attach_printable("Error while fetching the key store by merchant_id")?;
if business_profile.merchant_id != merchant_id {
Err(errors::ApiErrorResponse::AccessForbidden {
@ -2021,6 +2053,13 @@ pub async fn update_business_profile(
})
.transpose()?
.map(Secret::new);
let outgoing_webhook_custom_http_headers = request
.outgoing_webhook_custom_http_headers
.async_map(|headers| create_encrypted_data(&key_store, headers))
.await
.transpose()
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Unable to encrypt outgoing webhook custom HTTP headers")?;
let business_profile_update = storage::business_profile::BusinessProfileUpdate::Update {
profile_name: request.profile_name,
@ -2065,6 +2104,7 @@ pub async fn update_business_profile(
collect_billing_details_from_wallet_connector: request
.collect_billing_details_from_wallet_connector,
is_connector_agnostic_mit_enabled: request.is_connector_agnostic_mit_enabled,
outgoing_webhook_custom_http_headers: outgoing_webhook_custom_http_headers.map(Into::into),
};
let updated_business_profile = db
@ -2075,8 +2115,10 @@ pub async fn update_business_profile(
})?;
Ok(service_api::ApplicationResponse::Json(
api_models::admin::BusinessProfileResponse::foreign_try_from(updated_business_profile)
.change_context(errors::ApiErrorResponse::InternalServerError)?,
admin::business_profile_response(updated_business_profile, &key_store)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to parse business profile details")
.await?,
))
}

View File

@ -287,6 +287,7 @@ pub async fn update_business_profile_active_algorithm_ref(
collect_shipping_details_from_wallet_connector: None,
collect_billing_details_from_wallet_connector: None,
is_connector_agnostic_mit_enabled: None,
outgoing_webhook_custom_http_headers: None,
};
db.update_business_profile_by_profile_id(current_business_profile, business_profile_update)

View File

@ -612,7 +612,7 @@ async fn payments_incoming_webhook_flow(
// If event is NOT an UnsupportedEvent, trigger Outgoing Webhook
if let Some(outgoing_event_type) = event_type {
let primary_object_created_at = payments_response.created;
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -623,7 +623,7 @@ async fn payments_incoming_webhook_flow(
enums::EventObjectType::PaymentDetails,
api::OutgoingWebhookContent::PaymentDetails(payments_response),
primary_object_created_at,
)
))
.await?;
};
@ -735,7 +735,7 @@ async fn payouts_incoming_webhook_flow(
.attach_printable("Failed to fetch the payout create response")?,
};
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -746,7 +746,7 @@ async fn payouts_incoming_webhook_flow(
enums::EventObjectType::PayoutDetails,
api::OutgoingWebhookContent::PayoutDetails(payout_create_response),
Some(updated_payout_attempt.created_at),
)
))
.await?;
}
@ -840,7 +840,7 @@ async fn refunds_incoming_webhook_flow(
if let Some(outgoing_event_type) = event_type {
let refund_response: api_models::refunds::RefundResponse =
updated_refund.clone().foreign_into();
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -851,7 +851,7 @@ async fn refunds_incoming_webhook_flow(
enums::EventObjectType::RefundDetails,
api::OutgoingWebhookContent::RefundDetails(refund_response),
Some(updated_refund.created_at),
)
))
.await?;
}
@ -1116,7 +1116,7 @@ async fn external_authentication_incoming_webhook_flow(
// If event is NOT an UnsupportedEvent, trigger Outgoing Webhook
if let Some(outgoing_event_type) = event_type {
let primary_object_created_at = payments_response.created;
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -1127,7 +1127,7 @@ async fn external_authentication_incoming_webhook_flow(
enums::EventObjectType::PaymentDetails,
api::OutgoingWebhookContent::PaymentDetails(payments_response),
primary_object_created_at,
)
))
.await?;
};
let response = WebhookResponseTracker::Payment { payment_id, status };
@ -1214,7 +1214,7 @@ async fn mandates_incoming_webhook_flow(
);
let event_type: Option<enums::EventType> = updated_mandate.mandate_status.foreign_into();
if let Some(outgoing_event_type) = event_type {
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -1225,7 +1225,7 @@ async fn mandates_incoming_webhook_flow(
enums::EventObjectType::MandateDetails,
api::OutgoingWebhookContent::MandateDetails(mandates_response),
Some(updated_mandate.created_at),
)
))
.await?;
}
Ok(WebhookResponseTracker::Mandate {
@ -1323,7 +1323,7 @@ async fn frm_incoming_webhook_flow(
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;
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -1334,7 +1334,7 @@ async fn frm_incoming_webhook_flow(
enums::EventObjectType::PaymentDetails,
api::OutgoingWebhookContent::PaymentDetails(payments_response),
primary_object_created_at,
)
))
.await?;
};
let response = WebhookResponseTracker::Payment { payment_id, status };
@ -1397,7 +1397,7 @@ async fn disputes_incoming_webhook_flow(
let disputes_response = Box::new(dispute_object.clone().foreign_into());
let event_type: enums::EventType = dispute_object.dispute_status.foreign_into();
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -1408,7 +1408,7 @@ async fn disputes_incoming_webhook_flow(
enums::EventObjectType::DisputeDetails,
api::OutgoingWebhookContent::DisputeDetails(disputes_response),
Some(dispute_object.created_at),
)
))
.await?;
metrics::INCOMING_DISPUTE_WEBHOOK_MERCHANT_NOTIFIED_METRIC.add(&metrics::CONTEXT, 1, &[]);
Ok(WebhookResponseTracker::Dispute {
@ -1489,7 +1489,7 @@ async fn bank_transfer_webhook_flow(
// If event is NOT an UnsupportedEvent, trigger Outgoing Webhook
if let Some(outgoing_event_type) = event_type {
let primary_object_created_at = payments_response.created;
super::create_event_and_trigger_outgoing_webhook(
Box::pin(super::create_event_and_trigger_outgoing_webhook(
state,
merchant_account,
business_profile,
@ -1500,7 +1500,7 @@ async fn bank_transfer_webhook_flow(
enums::EventObjectType::PaymentDetails,
api::OutgoingWebhookContent::PaymentDetails(payments_response),
primary_object_created_at,
)
))
.await?;
}

View File

@ -1,3 +1,5 @@
use std::collections::HashMap;
use api_models::{
webhook_events::{OutgoingWebhookRequestContent, OutgoingWebhookResponseContent},
webhooks,
@ -5,6 +7,7 @@ use api_models::{
use common_utils::{ext_traits::Encode, request::RequestContent};
use diesel_models::process_tracker::business_status;
use error_stack::{report, ResultExt};
use hyperswitch_domain_models::type_encryption::decrypt;
use masking::{ExposeInterface, Mask, PeekInterface, Secret};
use router_env::{
instrument,
@ -86,8 +89,10 @@ pub(crate) async fn create_event_and_trigger_outgoing_webhook(
let request_content = get_outgoing_webhook_request(
&merchant_account,
outgoing_webhook,
business_profile.payment_response_hash_key.as_deref(),
&business_profile,
merchant_key_store,
)
.await
.change_context(errors::ApiErrorResponse::WebhookProcessingFailure)
.attach_printable("Failed to construct outgoing webhook request content")?;
@ -546,15 +551,17 @@ fn get_webhook_url_from_business_profile(
.map(ExposeInterface::expose)
}
pub(crate) fn get_outgoing_webhook_request(
pub(crate) async fn get_outgoing_webhook_request(
merchant_account: &domain::MerchantAccount,
outgoing_webhook: api::OutgoingWebhook,
payment_response_hash_key: Option<&str>,
business_profile: &diesel_models::business_profile::BusinessProfile,
key_store: &domain::MerchantKeyStore,
) -> CustomResult<OutgoingWebhookRequestContent, errors::WebhooksFlowError> {
#[inline]
fn get_outgoing_webhook_request_inner<WebhookType: types::OutgoingWebhookType>(
async fn get_outgoing_webhook_request_inner<WebhookType: types::OutgoingWebhookType>(
outgoing_webhook: api::OutgoingWebhook,
payment_response_hash_key: Option<&str>,
business_profile: &diesel_models::business_profile::BusinessProfile,
key_store: &domain::MerchantKeyStore,
) -> CustomResult<OutgoingWebhookRequestContent, errors::WebhooksFlowError> {
let mut headers = vec![(
reqwest::header::CONTENT_TYPE.to_string(),
@ -562,7 +569,31 @@ pub(crate) fn get_outgoing_webhook_request(
)];
let transformed_outgoing_webhook = WebhookType::from(outgoing_webhook);
let payment_response_hash_key = business_profile.payment_response_hash_key.clone();
let custom_headers = decrypt::<serde_json::Value, masking::WithType>(
business_profile
.outgoing_webhook_custom_http_headers
.clone(),
key_store.key.get_inner().peek(),
)
.await
.change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed)
.attach_printable("Failed to decrypt outgoing webhook custom HTTP headers")?
.map(|decrypted_value| {
decrypted_value
.into_inner()
.expose()
.parse_value::<HashMap<String, String>>("HashMap<String,String>")
.change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed)
.attach_printable("Failed to deserialize outgoing webhook custom HTTP headers")
})
.transpose()?;
if let Some(ref map) = custom_headers {
headers.extend(
map.iter()
.map(|(key, value)| (key.clone(), value.clone().into_masked())),
);
};
let outgoing_webhooks_signature = transformed_outgoing_webhook
.get_outgoing_webhooks_signature(payment_response_hash_key)?;
@ -581,15 +612,22 @@ pub(crate) fn get_outgoing_webhook_request(
match merchant_account.get_compatible_connector() {
#[cfg(feature = "stripe")]
Some(api_models::enums::Connector::Stripe) => get_outgoing_webhook_request_inner::<
stripe_webhooks::StripeOutgoingWebhook,
>(
outgoing_webhook, payment_response_hash_key
),
_ => get_outgoing_webhook_request_inner::<webhooks::OutgoingWebhook>(
outgoing_webhook,
payment_response_hash_key,
),
Some(api_models::enums::Connector::Stripe) => {
get_outgoing_webhook_request_inner::<stripe_webhooks::StripeOutgoingWebhook>(
outgoing_webhook,
business_profile,
key_store,
)
.await
}
_ => {
get_outgoing_webhook_request_inner::<webhooks::OutgoingWebhook>(
outgoing_webhook,
business_profile,
key_store,
)
.await
}
}
}

View File

@ -508,22 +508,22 @@ pub async fn business_profile_retrieve(
let flow = Flow::BusinessProfileRetrieve;
let (merchant_id, profile_id) = path.into_inner();
api::server_wrap(
Box::pin(api::server_wrap(
flow,
state,
&req,
profile_id,
|state, _, profile_id, _| retrieve_business_profile(state, profile_id),
|state, _, profile_id, _| retrieve_business_profile(state, profile_id, merchant_id.clone()),
auth::auth_type(
&auth::AdminApiAuth,
&auth::JWTAuthMerchantFromRoute {
merchant_id,
merchant_id: merchant_id.clone(),
required_permission: Permission::MerchantAccountRead,
},
req.headers(),
),
api_locking::LockAction::NotApplicable,
)
))
.await
}
#[instrument(skip_all, fields(flow = ?Flow::BusinessProfileUpdate))]
@ -583,7 +583,7 @@ pub async fn business_profiles_list(
let flow = Flow::BusinessProfileList;
let merchant_id = path.into_inner();
api::server_wrap(
Box::pin(api::server_wrap(
flow,
state,
&req,
@ -598,7 +598,7 @@ pub async fn business_profiles_list(
req.headers(),
),
api_locking::LockAction::NotApplicable,
)
))
.await
}

View File

@ -1,3 +1,5 @@
use std::collections::HashMap;
pub use api_models::admin::{
BusinessProfileCreate, BusinessProfileResponse, BusinessProfileUpdate, MerchantAccountCreate,
MerchantAccountDeleteResponse, MerchantAccountResponse, MerchantAccountUpdate,
@ -6,12 +8,13 @@ pub use api_models::admin::{
MerchantId, PaymentMethodsEnabled, ToggleAllKVRequest, ToggleAllKVResponse, ToggleKVRequest,
ToggleKVResponse, WebhookDetails,
};
use common_utils::ext_traits::{Encode, ValueExt};
use common_utils::ext_traits::{AsyncExt, Encode, ValueExt};
use error_stack::ResultExt;
use masking::{ExposeInterface, Secret};
use hyperswitch_domain_models::{merchant_key_store::MerchantKeyStore, type_encryption::decrypt};
use masking::{ExposeInterface, PeekInterface, Secret};
use crate::{
core::errors,
core::{errors, payment_methods::cards::create_encrypted_data},
types::{domain, storage, transformers::ForeignTryFrom},
};
@ -80,169 +83,189 @@ impl ForeignTryFrom<domain::MerchantAccount> for MerchantAccountResponse {
}
}
impl ForeignTryFrom<storage::business_profile::BusinessProfile> for BusinessProfileResponse {
type Error = error_stack::Report<errors::ParsingError>;
pub async fn business_profile_response(
item: storage::business_profile::BusinessProfile,
key_store: &MerchantKeyStore,
) -> Result<BusinessProfileResponse, error_stack::Report<errors::ParsingError>> {
let outgoing_webhook_custom_http_headers = decrypt::<serde_json::Value, masking::WithType>(
item.outgoing_webhook_custom_http_headers.clone(),
key_store.key.get_inner().peek(),
)
.await
.change_context(errors::ParsingError::StructParseFailure(
"Outgoing webhook custom HTTP headers",
))
.attach_printable("Failed to decrypt outgoing webhook custom HTTP headers")?
.map(|decrypted_value| {
decrypted_value
.into_inner()
.expose()
.parse_value::<HashMap<String, String>>("HashMap<String,String>")
})
.transpose()?;
fn foreign_try_from(
item: storage::business_profile::BusinessProfile,
) -> Result<Self, Self::Error> {
Ok(Self {
merchant_id: item.merchant_id,
profile_id: item.profile_id,
profile_name: item.profile_name,
return_url: item.return_url,
enable_payment_response_hash: item.enable_payment_response_hash,
payment_response_hash_key: item.payment_response_hash_key,
redirect_to_merchant_with_http_post: item.redirect_to_merchant_with_http_post,
webhook_details: item.webhook_details.map(Secret::new),
metadata: item.metadata,
routing_algorithm: item.routing_algorithm,
intent_fulfillment_time: item.intent_fulfillment_time,
frm_routing_algorithm: item.frm_routing_algorithm,
#[cfg(feature = "payouts")]
payout_routing_algorithm: item.payout_routing_algorithm,
applepay_verified_domains: item.applepay_verified_domains,
payment_link_config: item.payment_link_config,
session_expiry: item.session_expiry,
authentication_connector_details: item
.authentication_connector_details
.map(|authentication_connector_details| {
authentication_connector_details.parse_value("AuthenticationDetails")
})
.transpose()?,
payout_link_config: item
.payout_link_config
.map(|payout_link_config| {
payout_link_config.parse_value("BusinessPayoutLinkConfig")
})
.transpose()?,
use_billing_as_payment_method_billing: item.use_billing_as_payment_method_billing,
extended_card_info_config: item
.extended_card_info_config
.map(|config| config.expose().parse_value("ExtendedCardInfoConfig"))
.transpose()?,
collect_shipping_details_from_wallet_connector: item
.collect_shipping_details_from_wallet_connector,
collect_billing_details_from_wallet_connector: item
.collect_billing_details_from_wallet_connector,
is_connector_agnostic_mit_enabled: item.is_connector_agnostic_mit_enabled,
})
}
Ok(BusinessProfileResponse {
merchant_id: item.merchant_id,
profile_id: item.profile_id,
profile_name: item.profile_name,
return_url: item.return_url,
enable_payment_response_hash: item.enable_payment_response_hash,
payment_response_hash_key: item.payment_response_hash_key,
redirect_to_merchant_with_http_post: item.redirect_to_merchant_with_http_post,
webhook_details: item.webhook_details.map(Secret::new),
metadata: item.metadata,
routing_algorithm: item.routing_algorithm,
intent_fulfillment_time: item.intent_fulfillment_time,
frm_routing_algorithm: item.frm_routing_algorithm,
#[cfg(feature = "payouts")]
payout_routing_algorithm: item.payout_routing_algorithm,
applepay_verified_domains: item.applepay_verified_domains,
payment_link_config: item.payment_link_config,
session_expiry: item.session_expiry,
authentication_connector_details: item
.authentication_connector_details
.map(|authentication_connector_details| {
authentication_connector_details.parse_value("AuthenticationDetails")
})
.transpose()?,
payout_link_config: item
.payout_link_config
.map(|payout_link_config| payout_link_config.parse_value("BusinessPayoutLinkConfig"))
.transpose()?,
use_billing_as_payment_method_billing: item.use_billing_as_payment_method_billing,
extended_card_info_config: item
.extended_card_info_config
.map(|config| config.expose().parse_value("ExtendedCardInfoConfig"))
.transpose()?,
collect_shipping_details_from_wallet_connector: item
.collect_shipping_details_from_wallet_connector,
collect_billing_details_from_wallet_connector: item
.collect_billing_details_from_wallet_connector,
is_connector_agnostic_mit_enabled: item.is_connector_agnostic_mit_enabled,
outgoing_webhook_custom_http_headers,
})
}
impl ForeignTryFrom<(domain::MerchantAccount, BusinessProfileCreate)>
for storage::business_profile::BusinessProfileNew
{
type Error = error_stack::Report<errors::ApiErrorResponse>;
pub async fn create_business_profile(
merchant_account: domain::MerchantAccount,
request: BusinessProfileCreate,
key_store: &MerchantKeyStore,
) -> Result<
storage::business_profile::BusinessProfileNew,
error_stack::Report<errors::ApiErrorResponse>,
> {
// Generate a unique profile id
let profile_id = common_utils::generate_id_with_default_len("pro");
fn foreign_try_from(
(merchant_account, request): (domain::MerchantAccount, BusinessProfileCreate),
) -> Result<Self, Self::Error> {
// Generate a unique profile id
let profile_id = common_utils::generate_id_with_default_len("pro");
let current_time = common_utils::date_time::now();
let current_time = common_utils::date_time::now();
let webhook_details = request
.webhook_details
.as_ref()
.map(|webhook_details| {
webhook_details.encode_to_value().change_context(
errors::ApiErrorResponse::InvalidDataValue {
field_name: "webhook details",
},
)
})
.transpose()?;
let webhook_details = request
.webhook_details
let payment_response_hash_key = request
.payment_response_hash_key
.or(merchant_account.payment_response_hash_key)
.unwrap_or(common_utils::crypto::generate_cryptographically_secure_random_string(64));
let payment_link_config_value = request
.payment_link_config
.map(|pl_config| {
pl_config
.encode_to_value()
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config_value",
})
})
.transpose()?;
let outgoing_webhook_custom_http_headers = request
.outgoing_webhook_custom_http_headers
.async_map(|headers| create_encrypted_data(key_store, headers))
.await
.transpose()
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Unable to encrypt outgoing webhook custom HTTP headers")?;
Ok(storage::business_profile::BusinessProfileNew {
profile_id,
merchant_id: merchant_account.merchant_id,
profile_name: request.profile_name.unwrap_or("default".to_string()),
created_at: current_time,
modified_at: current_time,
return_url: request
.return_url
.map(|return_url| return_url.to_string())
.or(merchant_account.return_url),
enable_payment_response_hash: request
.enable_payment_response_hash
.unwrap_or(merchant_account.enable_payment_response_hash),
payment_response_hash_key: Some(payment_response_hash_key),
redirect_to_merchant_with_http_post: request
.redirect_to_merchant_with_http_post
.unwrap_or(merchant_account.redirect_to_merchant_with_http_post),
webhook_details: webhook_details.or(merchant_account.webhook_details),
metadata: request.metadata,
routing_algorithm: Some(serde_json::json!({
"algorithm_id": null,
"timestamp": 0
})),
intent_fulfillment_time: request
.intent_fulfillment_time
.map(i64::from)
.or(merchant_account.intent_fulfillment_time)
.or(Some(common_utils::consts::DEFAULT_INTENT_FULFILLMENT_TIME)),
frm_routing_algorithm: request
.frm_routing_algorithm
.or(merchant_account.frm_routing_algorithm),
#[cfg(feature = "payouts")]
payout_routing_algorithm: request
.payout_routing_algorithm
.or(merchant_account.payout_routing_algorithm),
#[cfg(not(feature = "payouts"))]
payout_routing_algorithm: None,
is_recon_enabled: merchant_account.is_recon_enabled,
applepay_verified_domains: request.applepay_verified_domains,
payment_link_config: payment_link_config_value,
session_expiry: request
.session_expiry
.map(i64::from)
.or(Some(common_utils::consts::DEFAULT_SESSION_EXPIRY)),
authentication_connector_details: request
.authentication_connector_details
.as_ref()
.map(|webhook_details| {
webhook_details.encode_to_value().change_context(
errors::ApiErrorResponse::InvalidDataValue {
field_name: "webhook details",
},
)
})
.transpose()?;
let payment_response_hash_key = request
.payment_response_hash_key
.or(merchant_account.payment_response_hash_key)
.unwrap_or(common_utils::crypto::generate_cryptographically_secure_random_string(64));
let payment_link_config_value = request
.payment_link_config
.map(|pl_config| {
pl_config.encode_to_value().change_context(
errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config_value",
},
)
})
.transpose()?;
Ok(Self {
profile_id,
merchant_id: merchant_account.merchant_id,
profile_name: request.profile_name.unwrap_or("default".to_string()),
created_at: current_time,
modified_at: current_time,
return_url: request
.return_url
.map(|return_url| return_url.to_string())
.or(merchant_account.return_url),
enable_payment_response_hash: request
.enable_payment_response_hash
.unwrap_or(merchant_account.enable_payment_response_hash),
payment_response_hash_key: Some(payment_response_hash_key),
redirect_to_merchant_with_http_post: request
.redirect_to_merchant_with_http_post
.unwrap_or(merchant_account.redirect_to_merchant_with_http_post),
webhook_details: webhook_details.or(merchant_account.webhook_details),
metadata: request.metadata,
routing_algorithm: Some(serde_json::json!({
"algorithm_id": null,
"timestamp": 0
})),
intent_fulfillment_time: request
.intent_fulfillment_time
.map(i64::from)
.or(merchant_account.intent_fulfillment_time)
.or(Some(common_utils::consts::DEFAULT_INTENT_FULFILLMENT_TIME)),
frm_routing_algorithm: request
.frm_routing_algorithm
.or(merchant_account.frm_routing_algorithm),
#[cfg(feature = "payouts")]
payout_routing_algorithm: request
.payout_routing_algorithm
.or(merchant_account.payout_routing_algorithm),
#[cfg(not(feature = "payouts"))]
payout_routing_algorithm: None,
is_recon_enabled: merchant_account.is_recon_enabled,
applepay_verified_domains: request.applepay_verified_domains,
payment_link_config: payment_link_config_value,
session_expiry: request
.session_expiry
.map(i64::from)
.or(Some(common_utils::consts::DEFAULT_SESSION_EXPIRY)),
authentication_connector_details: request
.authentication_connector_details
.as_ref()
.map(Encode::encode_to_value)
.transpose()
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "authentication_connector_details",
})?,
payout_link_config: request
.payout_link_config
.as_ref()
.map(Encode::encode_to_value)
.transpose()
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "payout_link_config",
})?,
is_connector_agnostic_mit_enabled: request.is_connector_agnostic_mit_enabled,
is_extended_card_info_enabled: None,
extended_card_info_config: None,
use_billing_as_payment_method_billing: request
.use_billing_as_payment_method_billing
.or(Some(true)),
collect_shipping_details_from_wallet_connector: request
.collect_shipping_details_from_wallet_connector
.or(Some(false)),
collect_billing_details_from_wallet_connector: request
.collect_billing_details_from_wallet_connector
.or(Some(false)),
})
}
.map(Encode::encode_to_value)
.transpose()
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "authentication_connector_details",
})?,
payout_link_config: request
.payout_link_config
.as_ref()
.map(Encode::encode_to_value)
.transpose()
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "payout_link_config",
})?,
is_connector_agnostic_mit_enabled: request.is_connector_agnostic_mit_enabled,
is_extended_card_info_enabled: None,
extended_card_info_config: None,
use_billing_as_payment_method_billing: request
.use_billing_as_payment_method_billing
.or(Some(true)),
collect_shipping_details_from_wallet_connector: request
.collect_shipping_details_from_wallet_connector
.or(Some(false)),
collect_billing_details_from_wallet_connector: request
.collect_billing_details_from_wallet_connector
.or(Some(false)),
outgoing_webhook_custom_http_headers: outgoing_webhook_custom_http_headers.map(Into::into),
})
}

View File

@ -164,8 +164,10 @@ impl ProcessTrackerWorkflow<SessionState> for OutgoingWebhookRetryWorkflow {
let request_content = webhooks_core::get_outgoing_webhook_request(
&merchant_account,
outgoing_webhook,
business_profile.payment_response_hash_key.as_deref(),
&business_profile,
&key_store,
)
.await
.map_err(|error| {
logger::error!(
?error,

View File

@ -0,0 +1 @@
ALTER TABLE business_profile DROP COLUMN IF EXISTS outgoing_webhook_custom_http_headers;

View File

@ -0,0 +1 @@
ALTER TABLE business_profile ADD COLUMN IF NOT EXISTS outgoing_webhook_custom_http_headers BYTEA DEFAULT NULL;