From d305fad2e6c403fde11b9eea785fddefbf3477df Mon Sep 17 00:00:00 2001 From: Kashif Date: Mon, 30 Jun 2025 12:38:04 +0530 Subject: [PATCH] feat(core): allow setting up status across payments, refunds and payouts for triggering webhooks in core resource flows (#8433) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- api-reference/v1/openapi_spec_v1.json | 40 +++++ api-reference/v2/openapi_spec_v2.json | 40 +++++ crates/api_models/src/admin.rs | 52 +++++++ crates/common_enums/src/enums.rs | 2 + crates/common_enums/src/transformers.rs | 82 +++++++++- crates/common_types/src/consts.rs | 23 +++ crates/diesel_models/src/business_profile.rs | 3 + crates/euclid_wasm/Cargo.toml | 4 +- crates/euclid_wasm/src/lib.rs | 46 +++++- .../src/business_profile.rs | 35 +++++ crates/router/src/core/payouts.rs | 31 +++- crates/router/src/core/webhooks/incoming.rs | 26 ++-- .../router/src/core/webhooks/incoming_v2.rs | 2 +- crates/router/src/routes/admin.rs | 18 ++- crates/router/src/routes/profiles.rs | 56 ++++++- crates/router/src/types/transformers.rs | 95 +---------- crates/router/src/utils.rs | 147 +++++++++++++----- .../src/workflows/outgoing_webhook_retry.rs | 19 +-- crates/router/tests/utils.rs | 3 - loadtest/k6/helper/setup.js | 10 +- 20 files changed, 545 insertions(+), 189 deletions(-) diff --git a/api-reference/v1/openapi_spec_v1.json b/api-reference/v1/openapi_spec_v1.json index db3e58b896..fe57a935d1 100644 --- a/api-reference/v1/openapi_spec_v1.json +++ b/api-reference/v1/openapi_spec_v1.json @@ -30881,6 +30881,10 @@ }, "WebhookDetails": { "type": "object", + "required": [ + "payment_statuses_enabled", + "refund_statuses_enabled" + ], "properties": { "webhook_version": { "type": "string", @@ -30926,6 +30930,42 @@ "description": "If this property is true, a webhook message is posted whenever a payment fails", "example": true, "nullable": true + }, + "payment_statuses_enabled": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntentStatus" + }, + "description": "List of payment statuses that triggers a webhook for payment intents", + "example": [ + "succeeded", + "failed", + "partially_captured", + "requires_merchant_action" + ] + }, + "refund_statuses_enabled": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntentStatus" + }, + "description": "List of refund statuses that triggers a webhook for refunds", + "example": [ + "success", + "failure" + ] + }, + "payout_statuses_enabled": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutStatus" + }, + "description": "List of payout statuses that triggers a webhook for payouts", + "example": [ + "success", + "failed" + ], + "nullable": true } }, "additionalProperties": false diff --git a/api-reference/v2/openapi_spec_v2.json b/api-reference/v2/openapi_spec_v2.json index 7aa4b11047..5ceb5f032f 100644 --- a/api-reference/v2/openapi_spec_v2.json +++ b/api-reference/v2/openapi_spec_v2.json @@ -25648,6 +25648,10 @@ }, "WebhookDetails": { "type": "object", + "required": [ + "payment_statuses_enabled", + "refund_statuses_enabled" + ], "properties": { "webhook_version": { "type": "string", @@ -25693,6 +25697,42 @@ "description": "If this property is true, a webhook message is posted whenever a payment fails", "example": true, "nullable": true + }, + "payment_statuses_enabled": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntentStatus" + }, + "description": "List of payment statuses that triggers a webhook for payment intents", + "example": [ + "succeeded", + "failed", + "partially_captured", + "requires_merchant_action" + ] + }, + "refund_statuses_enabled": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IntentStatus" + }, + "description": "List of refund statuses that triggers a webhook for refunds", + "example": [ + "success", + "failure" + ] + }, + "payout_statuses_enabled": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutStatus" + }, + "description": "List of payout statuses that triggers a webhook for payouts", + "example": [ + "success", + "failed" + ], + "nullable": true } }, "additionalProperties": false diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index f12e6d5bd4..00fccd4ed4 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -702,6 +702,58 @@ pub struct WebhookDetails { /// If this property is true, a webhook message is posted whenever a payment fails #[schema(example = true)] pub payment_failed_enabled: Option, + + /// List of payment statuses that triggers a webhook for payment intents + #[schema(value_type = Vec, example = json!(["succeeded", "failed", "partially_captured", "requires_merchant_action"]))] + pub payment_statuses_enabled: Option>, + + /// List of refund statuses that triggers a webhook for refunds + #[schema(value_type = Vec, example = json!(["success", "failure"]))] + pub refund_statuses_enabled: Option>, + + /// List of payout statuses that triggers a webhook for payouts + #[cfg(feature = "payouts")] + #[schema(value_type = Option>, example = json!(["success", "failed"]))] + pub payout_statuses_enabled: Option>, +} + +impl WebhookDetails { + fn validate_statuses(statuses: &[T], status_type_name: &str) -> Result<(), String> + where + T: strum::IntoEnumIterator + Copy + PartialEq + std::fmt::Debug, + T: Into>, + { + let valid_statuses: Vec = T::iter().filter(|s| (*s).into().is_some()).collect(); + + for status in statuses { + if !valid_statuses.contains(status) { + return Err(format!( + "Invalid {} webhook status provided: {:?}", + status_type_name, status + )); + } + } + Ok(()) + } + + pub fn validate(&self) -> Result<(), String> { + if let Some(payment_statuses) = &self.payment_statuses_enabled { + Self::validate_statuses(payment_statuses, "payment")?; + } + + if let Some(refund_statuses) = &self.refund_statuses_enabled { + Self::validate_statuses(refund_statuses, "refund")?; + } + + #[cfg(feature = "payouts")] + { + if let Some(payout_statuses) = &self.payout_statuses_enabled { + Self::validate_statuses(payout_statuses, "payout")?; + } + } + + Ok(()) + } } #[derive(Debug, Serialize, ToSchema)] diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index 6fbcc79819..29cf75ac50 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -2292,6 +2292,7 @@ pub enum FrmTransactionType { serde::Deserialize, serde::Serialize, strum::Display, + strum::EnumIter, strum::EnumString, ToSchema, )] @@ -6959,6 +6960,7 @@ pub enum BrazilStatesAbbreviation { serde::Deserialize, serde::Serialize, strum::Display, + strum::EnumIter, strum::EnumString, )] #[router_derive::diesel_enum(storage_type = "db_enum")] diff --git a/crates/common_enums/src/transformers.rs b/crates/common_enums/src/transformers.rs index 0ea21f5943..fb4555dea9 100644 --- a/crates/common_enums/src/transformers.rs +++ b/crates/common_enums/src/transformers.rs @@ -2,9 +2,11 @@ use std::fmt::{Display, Formatter}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "payouts")] +use crate::enums::PayoutStatus; use crate::enums::{ - AttemptStatus, Country, CountryAlpha2, CountryAlpha3, IntentStatus, PaymentMethod, - PaymentMethodType, + AttemptStatus, Country, CountryAlpha2, CountryAlpha3, DisputeStatus, EventType, IntentStatus, + MandateStatus, PaymentMethod, PaymentMethodType, RefundStatus, }; impl Display for NumericCountryCodeParseError { @@ -2119,6 +2121,82 @@ impl From for IntentStatus { } } +impl From for Option { + fn from(value: IntentStatus) -> Self { + match value { + IntentStatus::Succeeded => Some(EventType::PaymentSucceeded), + IntentStatus::Failed => Some(EventType::PaymentFailed), + IntentStatus::Processing => Some(EventType::PaymentProcessing), + IntentStatus::RequiresMerchantAction + | IntentStatus::RequiresCustomerAction + | IntentStatus::Conflicted => Some(EventType::ActionRequired), + IntentStatus::Cancelled => Some(EventType::PaymentCancelled), + IntentStatus::PartiallyCaptured | IntentStatus::PartiallyCapturedAndCapturable => { + Some(EventType::PaymentCaptured) + } + IntentStatus::RequiresCapture => Some(EventType::PaymentAuthorized), + IntentStatus::RequiresPaymentMethod | IntentStatus::RequiresConfirmation => None, + } + } +} + +impl From for Option { + fn from(value: RefundStatus) -> Self { + match value { + RefundStatus::Success => Some(EventType::RefundSucceeded), + RefundStatus::Failure => Some(EventType::RefundFailed), + RefundStatus::ManualReview + | RefundStatus::Pending + | RefundStatus::TransactionFailure => None, + } + } +} + +#[cfg(feature = "payouts")] +impl From for Option { + fn from(value: PayoutStatus) -> Self { + match value { + PayoutStatus::Success => Some(EventType::PayoutSuccess), + PayoutStatus::Failed => Some(EventType::PayoutFailed), + PayoutStatus::Cancelled => Some(EventType::PayoutCancelled), + PayoutStatus::Initiated => Some(EventType::PayoutInitiated), + PayoutStatus::Expired => Some(EventType::PayoutExpired), + PayoutStatus::Reversed => Some(EventType::PayoutReversed), + PayoutStatus::Ineligible + | PayoutStatus::Pending + | PayoutStatus::RequiresCreation + | PayoutStatus::RequiresFulfillment + | PayoutStatus::RequiresPayoutMethodData + | PayoutStatus::RequiresVendorAccountCreation + | PayoutStatus::RequiresConfirmation => None, + } + } +} + +impl From for EventType { + fn from(value: DisputeStatus) -> Self { + match value { + DisputeStatus::DisputeOpened => Self::DisputeOpened, + DisputeStatus::DisputeExpired => Self::DisputeExpired, + DisputeStatus::DisputeAccepted => Self::DisputeAccepted, + DisputeStatus::DisputeCancelled => Self::DisputeCancelled, + DisputeStatus::DisputeChallenged => Self::DisputeChallenged, + DisputeStatus::DisputeWon => Self::DisputeWon, + DisputeStatus::DisputeLost => Self::DisputeLost, + } + } +} + +impl From for Option { + fn from(value: MandateStatus) -> Self { + match value { + MandateStatus::Active => Some(EventType::MandateActive), + MandateStatus::Revoked => Some(EventType::MandateRevoked), + MandateStatus::Inactive | MandateStatus::Pending => None, + } + } +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] diff --git a/crates/common_types/src/consts.rs b/crates/common_types/src/consts.rs index 3550333443..1ceb999908 100644 --- a/crates/common_types/src/consts.rs +++ b/crates/common_types/src/consts.rs @@ -7,3 +7,26 @@ pub const API_VERSION: common_enums::ApiVersion = common_enums::ApiVersion::V1; /// API version #[cfg(feature = "v2")] pub const API_VERSION: common_enums::ApiVersion = common_enums::ApiVersion::V2; + +/// Default payment intent statuses that trigger a webhook +pub const DEFAULT_PAYMENT_WEBHOOK_TRIGGER_STATUSES: &[common_enums::IntentStatus] = &[ + common_enums::IntentStatus::Succeeded, + common_enums::IntentStatus::Failed, + common_enums::IntentStatus::PartiallyCaptured, + common_enums::IntentStatus::RequiresMerchantAction, +]; + +/// Default refund statuses that trigger a webhook +pub const DEFAULT_REFUND_WEBHOOK_TRIGGER_STATUSES: &[common_enums::RefundStatus] = &[ + common_enums::RefundStatus::Success, + common_enums::RefundStatus::Failure, + common_enums::RefundStatus::TransactionFailure, +]; + +/// Default payout statuses that trigger a webhook +pub const DEFAULT_PAYOUT_WEBHOOK_TRIGGER_STATUSES: &[common_enums::PayoutStatus] = &[ + common_enums::PayoutStatus::Success, + common_enums::PayoutStatus::Failed, + common_enums::PayoutStatus::Initiated, + common_enums::PayoutStatus::Pending, +]; diff --git a/crates/diesel_models/src/business_profile.rs b/crates/diesel_models/src/business_profile.rs index e2543e3b43..2c887e7f7c 100644 --- a/crates/diesel_models/src/business_profile.rs +++ b/crates/diesel_models/src/business_profile.rs @@ -747,6 +747,9 @@ pub struct WebhookDetails { pub payment_created_enabled: Option, pub payment_succeeded_enabled: Option, pub payment_failed_enabled: Option, + pub payment_statuses_enabled: Option>, + pub refund_statuses_enabled: Option>, + pub payout_statuses_enabled: Option>, } common_utils::impl_to_sql_from_sql_json!(WebhookDetails); diff --git a/crates/euclid_wasm/Cargo.toml b/crates/euclid_wasm/Cargo.toml index bf53014300..f8c9bbce62 100644 --- a/crates/euclid_wasm/Cargo.toml +++ b/crates/euclid_wasm/Cargo.toml @@ -15,8 +15,8 @@ release = ["payouts"] dummy_connector = ["kgraph_utils/dummy_connector", "connector_configs/dummy_connector"] production = ["connector_configs/production"] sandbox = ["connector_configs/sandbox"] -payouts = ["api_models/payouts", "euclid/payouts"] -v1 = ["api_models/v1", "kgraph_utils/v1"] +payouts = ["api_models/payouts", "common_enums/payouts", "euclid/payouts"] +v1 = ["api_models/v1", "kgraph_utils/v1", "payouts"] v2 = [] [dependencies] diff --git a/crates/euclid_wasm/src/lib.rs b/crates/euclid_wasm/src/lib.rs index 25d1da385a..c0e58520c7 100644 --- a/crates/euclid_wasm/src/lib.rs +++ b/crates/euclid_wasm/src/lib.rs @@ -34,7 +34,12 @@ use wasm_bindgen::prelude::*; use crate::utils::JsResultExt; type JsResult = Result; use api_models::payment_methods::CountryCodeWithName; -use common_enums::{CountryAlpha2, MerchantCategoryCode, MerchantCategoryCodeWithName}; +#[cfg(feature = "payouts")] +use common_enums::PayoutStatus; +use common_enums::{ + CountryAlpha2, DisputeStatus, EventClass, EventType, IntentStatus, MandateStatus, + MerchantCategoryCode, MerchantCategoryCodeWithName, RefundStatus, +}; use strum::IntoEnumIterator; struct SeedData { @@ -472,3 +477,42 @@ pub fn get_payout_description_category() -> JsResult { Ok(serde_wasm_bindgen::to_value(&category)?) } + +#[wasm_bindgen(js_name = getValidWebhookStatus)] +pub fn get_valid_webhook_status(key: &str) -> JsResult { + let event_class = EventClass::from_str(key) + .map_err(|_| "Invalid webhook event type received".to_string()) + .err_to_js()?; + + match event_class { + EventClass::Payments => { + let statuses: Vec = IntentStatus::iter() + .filter(|intent_status| Into::>::into(*intent_status).is_some()) + .collect(); + Ok(serde_wasm_bindgen::to_value(&statuses)?) + } + EventClass::Refunds => { + let statuses: Vec = RefundStatus::iter() + .filter(|status| Into::>::into(*status).is_some()) + .collect(); + Ok(serde_wasm_bindgen::to_value(&statuses)?) + } + EventClass::Disputes => { + let statuses: Vec = DisputeStatus::iter().collect(); + Ok(serde_wasm_bindgen::to_value(&statuses)?) + } + EventClass::Mandates => { + let statuses: Vec = MandateStatus::iter() + .filter(|status| Into::>::into(*status).is_some()) + .collect(); + Ok(serde_wasm_bindgen::to_value(&statuses)?) + } + #[cfg(feature = "payouts")] + EventClass::Payouts => { + let statuses: Vec = PayoutStatus::iter() + .filter(|status| Into::>::into(*status).is_some()) + .collect(); + Ok(serde_wasm_bindgen::to_value(&statuses)?) + } + } +} diff --git a/crates/hyperswitch_domain_models/src/business_profile.rs b/crates/hyperswitch_domain_models/src/business_profile.rs index c4d8b7dbc0..2a70f00c7f 100644 --- a/crates/hyperswitch_domain_models/src/business_profile.rs +++ b/crates/hyperswitch_domain_models/src/business_profile.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use common_enums::enums as api_enums; use common_types::{domain::AcquirerConfig, primitive_wrappers}; use common_utils::{ @@ -1268,6 +1270,39 @@ impl Profile { "unable to deserialize frm routing algorithm ref from merchant account", ) } + + pub fn get_payment_webhook_statuses(&self) -> Cow<'_, [common_enums::IntentStatus]> { + self.webhook_details + .as_ref() + .and_then(|details| details.payment_statuses_enabled.as_ref()) + .filter(|statuses_vec| !statuses_vec.is_empty()) + .map(|statuses_vec| Cow::Borrowed(statuses_vec.as_slice())) + .unwrap_or_else(|| { + Cow::Borrowed(common_types::consts::DEFAULT_PAYMENT_WEBHOOK_TRIGGER_STATUSES) + }) + } + + pub fn get_refund_webhook_statuses(&self) -> Cow<'_, [common_enums::RefundStatus]> { + self.webhook_details + .as_ref() + .and_then(|details| details.refund_statuses_enabled.as_ref()) + .filter(|statuses_vec| !statuses_vec.is_empty()) + .map(|statuses_vec| Cow::Borrowed(statuses_vec.as_slice())) + .unwrap_or_else(|| { + Cow::Borrowed(common_types::consts::DEFAULT_REFUND_WEBHOOK_TRIGGER_STATUSES) + }) + } + + pub fn get_payout_webhook_statuses(&self) -> Cow<'_, [common_enums::PayoutStatus]> { + self.webhook_details + .as_ref() + .and_then(|details| details.payout_statuses_enabled.as_ref()) + .filter(|statuses_vec| !statuses_vec.is_empty()) + .map(|statuses_vec| Cow::Borrowed(statuses_vec.as_slice())) + .unwrap_or_else(|| { + Cow::Borrowed(common_types::consts::DEFAULT_PAYOUT_WEBHOOK_TRIGGER_STATUSES) + }) + } } #[cfg(feature = "v2")] diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs index cbb9957115..495bea84ea 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -363,7 +363,7 @@ pub async fn payouts_create_core( .await? }; - response_handler(&state, &merchant_context, &payout_data).await + trigger_webhook_and_handle_response(&state, &merchant_context, &payout_data).await } #[instrument(skip_all)] @@ -426,7 +426,7 @@ pub async fn payouts_confirm_core( ) .await?; - response_handler(&state, &merchant_context, &payout_data).await + trigger_webhook_and_handle_response(&state, &merchant_context, &payout_data).await } pub async fn payouts_update_core( @@ -498,7 +498,7 @@ pub async fn payouts_update_core( .await?; } - response_handler(&state, &merchant_context, &payout_data).await + trigger_webhook_and_handle_response(&state, &merchant_context, &payout_data).await } #[cfg(all(feature = "payouts", feature = "v1"))] @@ -541,7 +541,9 @@ pub async fn payouts_retrieve_core( .await?; } - response_handler(&state, &merchant_context, &payout_data).await + Ok(services::ApplicationResponse::Json( + response_handler(&state, &merchant_context, &payout_data).await?, + )) } #[instrument(skip_all)] @@ -632,7 +634,9 @@ pub async fn payouts_cancel_core( .attach_printable("Payout cancellation failed for given Payout request")?; } - response_handler(&state, &merchant_context, &payout_data).await + Ok(services::ApplicationResponse::Json( + response_handler(&state, &merchant_context, &payout_data).await?, + )) } #[instrument(skip_all)] @@ -722,7 +726,7 @@ pub async fn payouts_fulfill_core( })); } - response_handler(&state, &merchant_context, &payout_data).await + trigger_webhook_and_handle_response(&state, &merchant_context, &payout_data).await } #[cfg(all(feature = "olap", feature = "v2"))] @@ -2481,11 +2485,21 @@ pub async fn fulfill_payout( Ok(()) } -pub async fn response_handler( +pub async fn trigger_webhook_and_handle_response( state: &SessionState, merchant_context: &domain::MerchantContext, payout_data: &PayoutData, ) -> RouterResponse { + let response = response_handler(state, merchant_context, payout_data).await?; + utils::trigger_payouts_webhook(state, merchant_context, &response).await?; + Ok(services::ApplicationResponse::Json(response)) +} + +pub async fn response_handler( + state: &SessionState, + merchant_context: &domain::MerchantContext, + payout_data: &PayoutData, +) -> RouterResult { let payout_attempt = payout_data.payout_attempt.to_owned(); let payouts = payout_data.payouts.to_owned(); @@ -2574,7 +2588,8 @@ pub async fn response_handler( .attach_printable("Failed to parse payout link's URL")?, payout_method_id, }; - Ok(services::ApplicationResponse::Json(response)) + + Ok(response) } #[cfg(feature = "v2")] diff --git a/crates/router/src/core/webhooks/incoming.rs b/crates/router/src/core/webhooks/incoming.rs index 51297b4387..5fef952532 100644 --- a/crates/router/src/core/webhooks/incoming.rs +++ b/crates/router/src/core/webhooks/incoming.rs @@ -864,7 +864,7 @@ async fn payments_incoming_webhook_flow( let status = payments_response.status; - let event_type: Option = payments_response.status.foreign_into(); + let event_type: Option = payments_response.status.into(); // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook if let Some(outgoing_event_type) = event_type { @@ -984,20 +984,13 @@ async fn payouts_incoming_webhook_flow( ) })?; - let event_type: Option = updated_payout_attempt.status.foreign_into(); + let event_type: Option = updated_payout_attempt.status.into(); // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook if let Some(outgoing_event_type) = event_type { - let router_response = + let payout_create_response = payouts::response_handler(&state, &merchant_context, &payout_data).await?; - let payout_create_response: payout_models::PayoutCreateResponse = match router_response - { - services::ApplicationResponse::Json(response) => response, - _ => Err(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the payout create response")?, - }; - Box::pin(super::create_event_and_trigger_outgoing_webhook( state, merchant_context, @@ -1192,7 +1185,7 @@ async fn refunds_incoming_webhook_flow( .await .attach_printable_lazy(|| format!("Failed while updating refund: refund_id: {refund_id}"))? }; - let event_type: Option = updated_refund.refund_status.foreign_into(); + let event_type: Option = updated_refund.refund_status.into(); // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook if let Some(outgoing_event_type) = event_type { @@ -1508,8 +1501,7 @@ async fn external_authentication_incoming_webhook_flow( let payment_id = payments_response.payment_id.clone(); let status = payments_response.status; - let event_type: Option = - payments_response.status.foreign_into(); + let event_type: Option = payments_response.status.into(); // Set poll_id as completed in redis to allow the fetch status of poll through retrieve_poll_status api from client let poll_id = core_utils::get_poll_id( merchant_context.get_merchant_account().get_id(), @@ -1627,7 +1619,7 @@ async fn mandates_incoming_webhook_flow( ) .await?, ); - let event_type: Option = updated_mandate.mandate_status.foreign_into(); + let event_type: Option = updated_mandate.mandate_status.into(); if let Some(outgoing_event_type) = event_type { Box::pin(super::create_event_and_trigger_outgoing_webhook( state, @@ -1730,7 +1722,7 @@ async fn frm_incoming_webhook_flow( services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { let payment_id = payments_response.payment_id.clone(); let status = payments_response.status; - let event_type: Option = payments_response.status.foreign_into(); + let event_type: Option = payments_response.status.into(); if let Some(outgoing_event_type) = event_type { let primary_object_created_at = payments_response.created; Box::pin(super::create_event_and_trigger_outgoing_webhook( @@ -1804,7 +1796,7 @@ async fn disputes_incoming_webhook_flow( ) .await?; let disputes_response = Box::new(dispute_object.clone().foreign_into()); - let event_type: enums::EventType = dispute_object.dispute_status.foreign_into(); + let event_type: enums::EventType = dispute_object.dispute_status.into(); Box::pin(super::create_event_and_trigger_outgoing_webhook( state, @@ -1886,7 +1878,7 @@ async fn bank_transfer_webhook_flow( services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { let payment_id = payments_response.payment_id.clone(); - let event_type: Option = payments_response.status.foreign_into(); + let event_type: Option = payments_response.status.into(); let status = payments_response.status; // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook diff --git a/crates/router/src/core/webhooks/incoming_v2.rs b/crates/router/src/core/webhooks/incoming_v2.rs index c6ff1e73ab..0cf8b2bfe7 100644 --- a/crates/router/src/core/webhooks/incoming_v2.rs +++ b/crates/router/src/core/webhooks/incoming_v2.rs @@ -535,7 +535,7 @@ async fn payments_incoming_webhook_flow( let status = payments_response.status; - let event_type: Option = payments_response.status.foreign_into(); + let event_type: Option = payments_response.status.into(); // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook if let Some(outgoing_event_type) = event_type { diff --git a/crates/router/src/routes/admin.rs b/crates/router/src/routes/admin.rs index 20de3c492e..6b88db236f 100644 --- a/crates/router/src/routes/admin.rs +++ b/crates/router/src/routes/admin.rs @@ -3,7 +3,7 @@ use router_env::{instrument, tracing, Flow}; use super::app::AppState; use crate::{ - core::{admin::*, api_locking}, + core::{admin::*, api_locking, errors}, services::{api, authentication as auth, authorization::permissions::Permission}, types::{api::admin, domain}, }; @@ -186,11 +186,25 @@ pub async fn merchant_account_create( json_payload: web::Json, ) -> HttpResponse { let flow = Flow::MerchantsAccountCreate; + let payload = json_payload.into_inner(); + if let Err(api_error) = payload + .webhook_details + .as_ref() + .map(|details| { + details + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + }) + .transpose() + { + return api::log_and_return_error_response(api_error.into()); + } + Box::pin(api::server_wrap( flow, state, &req, - json_payload.into_inner(), + payload, |state, auth, req, _| create_merchant_account(state, req, auth), &auth::PlatformOrgAdminAuth { is_admin_auth_allowed: true, diff --git a/crates/router/src/routes/profiles.rs b/crates/router/src/routes/profiles.rs index caa06c97b7..a854a68a2a 100644 --- a/crates/router/src/routes/profiles.rs +++ b/crates/router/src/routes/profiles.rs @@ -3,7 +3,7 @@ use router_env::{instrument, tracing, Flow}; use super::app::AppState; use crate::{ - core::{admin::*, api_locking}, + core::{admin::*, api_locking, errors}, services::{api, authentication as auth, authorization::permissions}, types::{api::admin, domain}, }; @@ -19,6 +19,18 @@ pub async fn profile_create( let flow = Flow::ProfileCreate; let payload = json_payload.into_inner(); let merchant_id = path.into_inner(); + if let Err(api_error) = payload + .webhook_details + .as_ref() + .map(|details| { + details + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + }) + .transpose() + { + return api::log_and_return_error_response(api_error.into()); + } Box::pin(api::server_wrap( flow, @@ -53,6 +65,18 @@ pub async fn profile_create( ) -> HttpResponse { let flow = Flow::ProfileCreate; let payload = json_payload.into_inner(); + if let Err(api_error) = payload + .webhook_details + .as_ref() + .map(|details| { + details + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + }) + .transpose() + { + return api::log_and_return_error_response(api_error.into()); + } Box::pin(api::server_wrap( flow, @@ -158,12 +182,25 @@ pub async fn profile_update( ) -> HttpResponse { let flow = Flow::ProfileUpdate; let (merchant_id, profile_id) = path.into_inner(); + let payload = json_payload.into_inner(); + if let Err(api_error) = payload + .webhook_details + .as_ref() + .map(|details| { + details + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + }) + .transpose() + { + return api::log_and_return_error_response(api_error.into()); + } Box::pin(api::server_wrap( flow, state, &req, - json_payload.into_inner(), + payload, |state, auth_data, req, _| update_profile(state, &profile_id, auth_data.key_store, req), auth::auth_type( &auth::HeaderAuth(auth::ApiKeyAuthWithMerchantIdFromRoute(merchant_id.clone())), @@ -189,12 +226,25 @@ pub async fn profile_update( ) -> HttpResponse { let flow = Flow::ProfileUpdate; let profile_id = path.into_inner(); + let payload = json_payload.into_inner(); + if let Err(api_error) = payload + .webhook_details + .as_ref() + .map(|details| { + details + .validate() + .map_err(|message| errors::ApiErrorResponse::InvalidRequestData { message }) + }) + .transpose() + { + return api::log_and_return_error_response(api_error.into()); + } Box::pin(api::server_wrap( flow, state, &req, - json_payload.into_inner(), + payload, |state, auth::AuthenticationDataWithoutProfile { key_store, .. }, req, _| { update_profile(state, &profile_id, key_store, req) }, diff --git a/crates/router/src/types/transformers.rs b/crates/router/src/types/transformers.rs index 3a1c89a016..c66c12b154 100644 --- a/crates/router/src/types/transformers.rs +++ b/crates/router/src/types/transformers.rs @@ -443,31 +443,6 @@ impl ForeignFrom for storage_enums::MandateAmountDa } } -impl ForeignFrom for Option { - fn foreign_from(value: api_enums::IntentStatus) -> Self { - match value { - api_enums::IntentStatus::Succeeded => Some(storage_enums::EventType::PaymentSucceeded), - api_enums::IntentStatus::Failed => Some(storage_enums::EventType::PaymentFailed), - api_enums::IntentStatus::Processing => { - Some(storage_enums::EventType::PaymentProcessing) - } - api_enums::IntentStatus::RequiresMerchantAction - | api_enums::IntentStatus::RequiresCustomerAction - | api_enums::IntentStatus::Conflicted => Some(storage_enums::EventType::ActionRequired), - api_enums::IntentStatus::Cancelled => Some(storage_enums::EventType::PaymentCancelled), - api_enums::IntentStatus::PartiallyCaptured - | api_enums::IntentStatus::PartiallyCapturedAndCapturable => { - Some(storage_enums::EventType::PaymentCaptured) - } - api_enums::IntentStatus::RequiresCapture => { - Some(storage_enums::EventType::PaymentAuthorized) - } - api_enums::IntentStatus::RequiresPaymentMethod - | api_enums::IntentStatus::RequiresConfirmation => None, - } - } -} - impl ForeignFrom for api_enums::PaymentMethod { fn foreign_from(payment_method_type: api_enums::PaymentMethodType) -> Self { match payment_method_type { @@ -614,66 +589,6 @@ impl ForeignTryFrom for api_enums::PaymentMethod { } } -impl ForeignFrom for Option { - fn foreign_from(value: storage_enums::RefundStatus) -> Self { - match value { - storage_enums::RefundStatus::Success => Some(storage_enums::EventType::RefundSucceeded), - storage_enums::RefundStatus::Failure => Some(storage_enums::EventType::RefundFailed), - api_enums::RefundStatus::ManualReview - | api_enums::RefundStatus::Pending - | api_enums::RefundStatus::TransactionFailure => None, - } - } -} - -impl ForeignFrom for Option { - fn foreign_from(value: storage_enums::PayoutStatus) -> Self { - match value { - storage_enums::PayoutStatus::Success => Some(storage_enums::EventType::PayoutSuccess), - storage_enums::PayoutStatus::Failed => Some(storage_enums::EventType::PayoutFailed), - storage_enums::PayoutStatus::Cancelled => { - Some(storage_enums::EventType::PayoutCancelled) - } - storage_enums::PayoutStatus::Initiated => { - Some(storage_enums::EventType::PayoutInitiated) - } - storage_enums::PayoutStatus::Expired => Some(storage_enums::EventType::PayoutExpired), - storage_enums::PayoutStatus::Reversed => Some(storage_enums::EventType::PayoutReversed), - storage_enums::PayoutStatus::Ineligible - | storage_enums::PayoutStatus::Pending - | storage_enums::PayoutStatus::RequiresCreation - | storage_enums::PayoutStatus::RequiresFulfillment - | storage_enums::PayoutStatus::RequiresPayoutMethodData - | storage_enums::PayoutStatus::RequiresVendorAccountCreation - | storage_enums::PayoutStatus::RequiresConfirmation => None, - } - } -} - -impl ForeignFrom for storage_enums::EventType { - fn foreign_from(value: storage_enums::DisputeStatus) -> Self { - match value { - storage_enums::DisputeStatus::DisputeOpened => Self::DisputeOpened, - storage_enums::DisputeStatus::DisputeExpired => Self::DisputeExpired, - storage_enums::DisputeStatus::DisputeAccepted => Self::DisputeAccepted, - storage_enums::DisputeStatus::DisputeCancelled => Self::DisputeCancelled, - storage_enums::DisputeStatus::DisputeChallenged => Self::DisputeChallenged, - storage_enums::DisputeStatus::DisputeWon => Self::DisputeWon, - storage_enums::DisputeStatus::DisputeLost => Self::DisputeLost, - } - } -} - -impl ForeignFrom for Option { - fn foreign_from(value: storage_enums::MandateStatus) -> Self { - match value { - storage_enums::MandateStatus::Active => Some(storage_enums::EventType::MandateActive), - storage_enums::MandateStatus::Revoked => Some(storage_enums::EventType::MandateRevoked), - storage_enums::MandateStatus::Inactive | storage_enums::MandateStatus::Pending => None, - } - } -} - impl ForeignTryFrom for storage_enums::RefundStatus { type Error = errors::ValidationError; @@ -2135,8 +2050,11 @@ impl ForeignFrom webhook_password: item.webhook_password, webhook_url: item.webhook_url, payment_created_enabled: item.payment_created_enabled, - payment_succeeded_enabled: item.payment_succeeded_enabled, payment_failed_enabled: item.payment_failed_enabled, + payment_succeeded_enabled: item.payment_succeeded_enabled, + payment_statuses_enabled: item.payment_statuses_enabled, + refund_statuses_enabled: item.refund_statuses_enabled, + payout_statuses_enabled: item.payout_statuses_enabled, } } } @@ -2151,8 +2069,11 @@ impl ForeignFrom webhook_password: item.webhook_password, webhook_url: item.webhook_url, payment_created_enabled: item.payment_created_enabled, - payment_succeeded_enabled: item.payment_succeeded_enabled, payment_failed_enabled: item.payment_failed_enabled, + payment_succeeded_enabled: item.payment_succeeded_enabled, + payment_statuses_enabled: item.payment_statuses_enabled, + refund_statuses_enabled: item.refund_statuses_enabled, + payout_statuses_enabled: item.payout_statuses_enabled, } } } diff --git a/crates/router/src/utils.rs b/crates/router/src/utils.rs index 11818b0569..f89dbbec70 100644 --- a/crates/router/src/utils.rs +++ b/crates/router/src/utils.rs @@ -55,10 +55,7 @@ use crate::{ logger, routes::{metrics, SessionState}, services::{self, authentication::get_header_value_by_key}, - types::{ - self, domain, - transformers::{ForeignFrom, ForeignInto}, - }, + types::{self, domain, transformers::ForeignInto}, }; #[cfg(feature = "v1")] use crate::{core::webhooks as webhooks_core, types::storage}; @@ -1156,25 +1153,21 @@ where D: payments_core::OperationSessionGetters, { let status = payment_data.get_payment_intent().status; - let payment_id = payment_data.get_payment_intent().get_id().to_owned(); + let should_trigger_webhook = business_profile + .get_payment_webhook_statuses() + .contains(&status); - let captures = payment_data - .get_multiple_capture_data() - .map(|multiple_capture_data| { - multiple_capture_data - .get_all_captures() - .into_iter() - .cloned() - .collect() - }); - - if matches!( - status, - enums::IntentStatus::Succeeded - | enums::IntentStatus::Failed - | enums::IntentStatus::PartiallyCaptured - | enums::IntentStatus::RequiresMerchantAction - ) { + if should_trigger_webhook { + let captures = payment_data + .get_multiple_capture_data() + .map(|multiple_capture_data| { + multiple_capture_data + .get_all_captures() + .into_iter() + .cloned() + .collect() + }); + let payment_id = payment_data.get_payment_intent().get_id().to_owned(); let payments_response = crate::core::payments::transformers::payments_to_payments_response( payment_data, captures, @@ -1188,7 +1181,7 @@ where None, )?; - let event_type = ForeignFrom::foreign_from(status); + let event_type = status.into(); if let services::ApplicationResponse::JsonWithHeaders((payments_response_json, _)) = payments_response @@ -1250,27 +1243,28 @@ pub async fn trigger_refund_outgoing_webhook( profile_id: id_type::ProfileId, ) -> RouterResult<()> { let refund_status = refund.refund_status; - if matches!( - refund_status, - enums::RefundStatus::Success - | enums::RefundStatus::Failure - | enums::RefundStatus::TransactionFailure - ) { - let event_type = ForeignFrom::foreign_from(refund_status); + + let key_manager_state = &(state).into(); + let business_profile = state + .store + .find_business_profile_by_profile_id( + key_manager_state, + merchant_context.get_merchant_key_store(), + &profile_id, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::ProfileNotFound { + id: profile_id.get_string_repr().to_owned(), + })?; + + let should_trigger_webhook = business_profile + .get_refund_webhook_statuses() + .contains(&refund_status); + + if should_trigger_webhook { + let event_type = refund_status.into(); let refund_response: api_models::refunds::RefundResponse = refund.clone().foreign_into(); - let key_manager_state = &(state).into(); let refund_id = refund_response.refund_id.clone(); - let business_profile = state - .store - .find_business_profile_by_profile_id( - key_manager_state, - merchant_context.get_merchant_key_store(), - &profile_id, - ) - .await - .to_not_found_response(errors::ApiErrorResponse::ProfileNotFound { - id: profile_id.get_string_repr().to_owned(), - })?; let cloned_state = state.clone(); let cloned_merchant_context = merchant_context.clone(); let primary_object_created_at = refund_response.created_at; @@ -1317,3 +1311,72 @@ pub fn get_locale_from_header(headers: &actix_web::http::header::HeaderMap) -> S .map(|val| val.to_string()) .unwrap_or(common_utils::consts::DEFAULT_LOCALE.to_string()) } + +#[cfg(all(feature = "payouts", feature = "v1"))] +pub async fn trigger_payouts_webhook( + state: &SessionState, + merchant_context: &domain::MerchantContext, + payout_response: &api_models::payouts::PayoutCreateResponse, +) -> RouterResult<()> { + let key_manager_state = &(state).into(); + let profile_id = &payout_response.profile_id; + let business_profile = state + .store + .find_business_profile_by_profile_id( + key_manager_state, + merchant_context.get_merchant_key_store(), + profile_id, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::ProfileNotFound { + id: profile_id.get_string_repr().to_owned(), + })?; + + let status = &payout_response.status; + let should_trigger_webhook = business_profile + .get_payout_webhook_statuses() + .contains(status); + + if should_trigger_webhook { + let event_type = (*status).into(); + if let Some(event_type) = event_type { + let cloned_merchant_context = merchant_context.clone(); + let cloned_state = state.clone(); + let cloned_response = payout_response.clone(); + + // This spawns this futures in a background thread, the exception inside this future won't affect + // the current thread and the lifecycle of spawn thread is not handled by runtime. + // So when server shutdown won't wait for this thread's completion. + tokio::spawn( + async move { + let primary_object_created_at = cloned_response.created; + Box::pin(webhooks_core::create_event_and_trigger_outgoing_webhook( + cloned_state, + cloned_merchant_context, + business_profile, + event_type, + diesel_models::enums::EventClass::Payouts, + cloned_response.payout_id.clone(), + diesel_models::enums::EventObjectType::PayoutDetails, + webhooks::OutgoingWebhookContent::PayoutDetails(Box::new(cloned_response)), + primary_object_created_at, + )) + .await + } + .in_current_span(), + ); + } else { + logger::warn!("Outgoing webhook not sent because of missing event type status mapping"); + } + } + Ok(()) +} + +#[cfg(all(feature = "payouts", feature = "v2"))] +pub async fn trigger_payouts_webhook( + state: &SessionState, + merchant_context: &domain::MerchantContext, + payout_response: &api_models::payouts::PayoutCreateResponse, +) -> RouterResult<()> { + todo!() +} diff --git a/crates/router/src/workflows/outgoing_webhook_retry.rs b/crates/router/src/workflows/outgoing_webhook_retry.rs index d525763529..8a04094f31 100644 --- a/crates/router/src/workflows/outgoing_webhook_retry.rs +++ b/crates/router/src/workflows/outgoing_webhook_retry.rs @@ -438,7 +438,7 @@ async fn get_outgoing_webhook_content_and_event_type( }) } }?; - let event_type = Option::::foreign_from(payments_response.status); + let event_type: Option = payments_response.status.into(); logger::debug!(current_resource_status=%payments_response.status); Ok(( @@ -462,7 +462,7 @@ async fn get_outgoing_webhook_content_and_event_type( request, )) .await?; - let event_type = Option::::foreign_from(refund.refund_status); + let event_type: Option = refund.refund_status.into(); logger::debug!(current_resource_status=%refund.refund_status); let refund_response = RefundResponse::foreign_from(refund); @@ -495,7 +495,7 @@ async fn get_outgoing_webhook_content_and_event_type( } } .map(Box::new)?; - let event_type = Some(EventType::foreign_from(dispute_response.dispute_status)); + let event_type = Some(EventType::from(dispute_response.dispute_status)); logger::debug!(current_resource_status=%dispute_response.dispute_status); Ok(( @@ -527,7 +527,7 @@ async fn get_outgoing_webhook_content_and_event_type( } } .map(Box::new)?; - let event_type = Option::::foreign_from(mandate_response.status); + let event_type: Option = mandate_response.status.into(); logger::debug!(current_resource_status=%mandate_response.status); Ok(( @@ -551,17 +551,10 @@ async fn get_outgoing_webhook_content_and_event_type( )) .await?; - let router_response = + let payout_create_response = payouts::response_handler(&state, &merchant_context, &payout_data).await?; - let payout_create_response: payout_models::PayoutCreateResponse = match router_response - { - ApplicationResponse::Json(response) => response, - _ => Err(errors::ApiErrorResponse::WebhookResourceNotFound) - .attach_printable("Failed to fetch the payout create response")?, - }; - - let event_type = Option::::foreign_from(payout_data.payout_attempt.status); + let event_type: Option = payout_data.payout_attempt.status.into(); logger::debug!(current_resource_status=%payout_data.payout_attempt.status); Ok(( diff --git a/crates/router/tests/utils.rs b/crates/router/tests/utils.rs index eb8ed7090d..20b83f4748 100644 --- a/crates/router/tests/utils.rs +++ b/crates/router/tests/utils.rs @@ -220,9 +220,6 @@ fn mk_merchant_account(merchant_id: Option) -> Value { "webhook_version": "1.0.1", "webhook_username": "ekart_retail", "webhook_password": "password_ekart@123", - "payment_created_enabled": true, - "payment_succeeded_enabled": true, - "payment_failed_enabled": true }, "routing_algorithm": { "type": "single", diff --git a/loadtest/k6/helper/setup.js b/loadtest/k6/helper/setup.js index 79e0146329..18f10242a6 100644 --- a/loadtest/k6/helper/setup.js +++ b/loadtest/k6/helper/setup.js @@ -34,10 +34,7 @@ export function setup_merchant_apikey() { "webhook_details":{ "webhook_version":"1.0.1", "webhook_username":"wh_store", - "webhook_password":"pwd_wh@101", - "payment_created_enabled":true, - "payment_succeeded_enabled":true, - "payment_failed_enabled":true + "webhook_password":"pwd_wh@101" }, "routing_algorithm": { "type": "single", @@ -126,10 +123,7 @@ export function setup_merchant_apikey() { "webhook_details":{ "webhook_version":"1.0.1", "webhook_username":"wh_store", - "webhook_password":"pwd_wh@101", - "payment_created_enabled":true, - "payment_succeeded_enabled":true, - "payment_failed_enabled":true + "webhook_password":"pwd_wh@101" }, "routing_algorithm": { "type": "single",