mirror of
				https://github.com/juspay/hyperswitch.git
				synced 2025-10-30 17:47:54 +08:00 
			
		
		
		
	feat(webhooks): allow manually retrying delivery of outgoing webhooks (#4176)
This commit is contained in:
		| @ -34,6 +34,7 @@ pub enum Permission { | ||||
|     WebhookEventRead, | ||||
|     PayoutWrite, | ||||
|     PayoutRead, | ||||
|     WebhookEventWrite, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
|  | ||||
| @ -94,6 +94,14 @@ pub struct EventRetrieveResponse { | ||||
|     pub delivery_attempt: Option<WebhookDeliveryAttempt>, | ||||
| } | ||||
|  | ||||
| impl common_utils::events::ApiEventMetric for EventRetrieveResponse { | ||||
|     fn get_api_event_type(&self) -> Option<common_utils::events::ApiEventsType> { | ||||
|         Some(common_utils::events::ApiEventsType::Events { | ||||
|             merchant_id_or_profile_id: self.event_information.merchant_id.clone(), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// The request information (headers and body) sent in the webhook. | ||||
| #[derive(Debug, Serialize, Deserialize, ToSchema)] | ||||
| pub struct OutgoingWebhookRequestContent { | ||||
| @ -114,20 +122,24 @@ pub struct OutgoingWebhookRequestContent { | ||||
| #[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)] | ||||
| pub struct OutgoingWebhookResponseContent { | ||||
|     /// The response body received for the webhook sent. | ||||
|     #[schema(value_type = String)] | ||||
|     #[schema(value_type = Option<String>)] | ||||
|     #[serde(alias = "payload")] | ||||
|     pub body: Secret<String>, | ||||
|     pub body: Option<Secret<String>>, | ||||
|  | ||||
|     /// The response headers received for the webhook sent. | ||||
|     #[schema( | ||||
|         value_type = Vec<(String, String)>, | ||||
|         value_type = Option<Vec<(String, String)>>, | ||||
|         example = json!([["content-type", "application/json"], ["content-length", "1024"]])) | ||||
|     ] | ||||
|     pub headers: Vec<(String, Secret<String>)>, | ||||
|     pub headers: Option<Vec<(String, Secret<String>)>>, | ||||
|  | ||||
|     /// The HTTP status code for the webhook sent. | ||||
|     #[schema(example = 200)] | ||||
|     pub status_code: u16, | ||||
|     pub status_code: Option<u16>, | ||||
|  | ||||
|     /// Error message in case any error occurred when trying to deliver the webhook. | ||||
|     #[schema(example = 200)] | ||||
|     pub error_message: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| @ -157,3 +169,17 @@ impl common_utils::events::ApiEventMetric for WebhookDeliveryAttemptListRequestI | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct WebhookDeliveryRetryRequestInternal { | ||||
|     pub merchant_id_or_profile_id: String, | ||||
|     pub event_id: String, | ||||
| } | ||||
|  | ||||
| impl common_utils::events::ApiEventMetric for WebhookDeliveryRetryRequestInternal { | ||||
|     fn get_api_event_type(&self) -> Option<common_utils::events::ApiEventsType> { | ||||
|         Some(common_utils::events::ApiEventsType::Events { | ||||
|             merchant_id_or_profile_id: self.merchant_id_or_profile_id.clone(), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -177,6 +177,7 @@ Never share your secret api keys. Keep them guarded and secure. | ||||
|         // Routes for events | ||||
|         routes::webhook_events::list_initial_webhook_delivery_attempts, | ||||
|         routes::webhook_events::list_webhook_delivery_attempts, | ||||
|         routes::webhook_events::retry_webhook_delivery_attempt, | ||||
|     ), | ||||
|     components(schemas( | ||||
|         api_models::refunds::RefundRequest, | ||||
|  | ||||
| @ -68,3 +68,27 @@ pub fn list_initial_webhook_delivery_attempts() {} | ||||
|     security(("admin_api_key" = [])) | ||||
| )] | ||||
| pub fn list_webhook_delivery_attempts() {} | ||||
|  | ||||
| /// Events - Manual Retry | ||||
| /// | ||||
| /// Manually retry the delivery of the specified Event. | ||||
| #[utoipa::path( | ||||
|     post, | ||||
|     path = "/events/{merchant_id_or_profile_id}/{event_id}/retry", | ||||
|     params( | ||||
|         ("merchant_id_or_profile_id" = String, Path, description = "The unique identifier for the Merchant Account or Business Profile"), | ||||
|         ("event_id" = String, Path, description = "The unique identifier for the Event"), | ||||
|     ), | ||||
|     responses( | ||||
|         ( | ||||
|             status = 200, | ||||
|             description = "The delivery of the Event was attempted. \ | ||||
|                            Check the `response` field in the response payload to identify the status of the delivery attempt.", | ||||
|             body = EventRetrieveResponse | ||||
|         ), | ||||
|     ), | ||||
|     tag = "Event", | ||||
|     operation_id = "Manually retry the delivery of an Event", | ||||
|     security(("admin_api_key" = [])) | ||||
| )] | ||||
| pub fn retry_webhook_delivery_attempt() {} | ||||
|  | ||||
| @ -887,9 +887,69 @@ async fn trigger_webhook_to_merchant( | ||||
|     ); | ||||
|     logger::debug!(outgoing_webhook_response=?response); | ||||
|  | ||||
|     let update_event_if_client_error = | ||||
|         |state: AppState, | ||||
|          merchant_key_store: domain::MerchantKeyStore, | ||||
|          merchant_id: String, | ||||
|          event_id: String, | ||||
|          error_message: String| async move { | ||||
|             let is_webhook_notified = false; | ||||
|  | ||||
|             let response_to_store = OutgoingWebhookResponseContent { | ||||
|                 body: None, | ||||
|                 headers: None, | ||||
|                 status_code: None, | ||||
|                 error_message: Some(error_message), | ||||
|             }; | ||||
|  | ||||
|             let event_update = domain::EventUpdate::UpdateResponse { | ||||
|                 is_webhook_notified, | ||||
|                 response: Some( | ||||
|                     domain_types::encrypt( | ||||
|                         response_to_store | ||||
|                             .encode_to_string_of_json() | ||||
|                             .change_context( | ||||
|                                 errors::WebhooksFlowError::OutgoingWebhookResponseEncodingFailed, | ||||
|                             ) | ||||
|                             .map(Secret::new)?, | ||||
|                         merchant_key_store.key.get_inner().peek(), | ||||
|                     ) | ||||
|                     .await | ||||
|                     .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) | ||||
|                     .attach_printable("Failed to encrypt outgoing webhook response content")?, | ||||
|                 ), | ||||
|             }; | ||||
|  | ||||
|             state | ||||
|                 .store | ||||
|                 .update_event_by_merchant_id_event_id( | ||||
|                     &merchant_id, | ||||
|                     &event_id, | ||||
|                     event_update, | ||||
|                     &merchant_key_store, | ||||
|                 ) | ||||
|                 .await | ||||
|                 .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) | ||||
|         }; | ||||
|  | ||||
|     let api_client_error_handler = | ||||
|         |client_error: error_stack::Report<errors::ApiClientError>, | ||||
|          delivery_attempt: enums::WebhookDeliveryAttempt| { | ||||
|         |state: AppState, | ||||
|          merchant_key_store: domain::MerchantKeyStore, | ||||
|          merchant_id: String, | ||||
|          event_id: String, | ||||
|          client_error: error_stack::Report<errors::ApiClientError>, | ||||
|          delivery_attempt: enums::WebhookDeliveryAttempt| async move { | ||||
|             // Not including detailed error message in response information since it contains too | ||||
|             // much of diagnostic information to be exposed to the merchant. | ||||
|             update_event_if_client_error( | ||||
|                 state, | ||||
|                 merchant_key_store, | ||||
|                 merchant_id, | ||||
|                 event_id, | ||||
|                 "Unable to send request to merchant server".to_string(), | ||||
|             ) | ||||
|             .await?; | ||||
|  | ||||
|             let error = | ||||
|                 client_error.change_context(errors::WebhooksFlowError::CallToMerchantFailed); | ||||
|             logger::error!( | ||||
| @ -897,6 +957,8 @@ async fn trigger_webhook_to_merchant( | ||||
|                 ?delivery_attempt, | ||||
|                 "An error occurred when sending webhook to merchant" | ||||
|             ); | ||||
|  | ||||
|             Ok::<_, error_stack::Report<errors::WebhooksFlowError>>(()) | ||||
|         }; | ||||
|     let update_event_in_storage = |state: AppState, | ||||
|                                    merchant_key_store: domain::MerchantKeyStore, | ||||
| @ -934,9 +996,10 @@ async fn trigger_webhook_to_merchant( | ||||
|                 Secret::from(String::from("Non-UTF-8 response body")) | ||||
|             }); | ||||
|         let response_to_store = OutgoingWebhookResponseContent { | ||||
|             body: response_body, | ||||
|             headers: response_headers, | ||||
|             status_code: status_code.as_u16(), | ||||
|             body: Some(response_body), | ||||
|             headers: Some(response_headers), | ||||
|             status_code: Some(status_code.as_u16()), | ||||
|             error_message: None, | ||||
|         }; | ||||
|  | ||||
|         let event_update = domain::EventUpdate::UpdateResponse { | ||||
| @ -953,7 +1016,7 @@ async fn trigger_webhook_to_merchant( | ||||
|                 ) | ||||
|                 .await | ||||
|                 .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) | ||||
|                 .attach_printable("Failed to encrypt outgoing webhook request content")?, | ||||
|                 .attach_printable("Failed to encrypt outgoing webhook response content")?, | ||||
|             ), | ||||
|         }; | ||||
|         state | ||||
| @ -967,16 +1030,19 @@ async fn trigger_webhook_to_merchant( | ||||
|             .await | ||||
|             .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed) | ||||
|     }; | ||||
|     let increment_webhook_outgoing_received_count = |merchant_id: String| { | ||||
|         metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( | ||||
|             &metrics::CONTEXT, | ||||
|             1, | ||||
|             &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], | ||||
|         ) | ||||
|     }; | ||||
|     let success_response_handler = | ||||
|         |state: AppState, | ||||
|          merchant_id: String, | ||||
|          process_tracker: Option<storage::ProcessTracker>, | ||||
|          business_status: &'static str| async move { | ||||
|             metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( | ||||
|                 &metrics::CONTEXT, | ||||
|                 1, | ||||
|                 &[metrics::KeyValue::new(MERCHANT_ID, merchant_id)], | ||||
|             ); | ||||
|             increment_webhook_outgoing_received_count(merchant_id); | ||||
|  | ||||
|             match process_tracker { | ||||
|                 Some(process_tracker) => state | ||||
| @ -1006,7 +1072,17 @@ async fn trigger_webhook_to_merchant( | ||||
|  | ||||
|     match delivery_attempt { | ||||
|         enums::WebhookDeliveryAttempt::InitialAttempt => match response { | ||||
|             Err(client_error) => api_client_error_handler(client_error, delivery_attempt), | ||||
|             Err(client_error) => { | ||||
|                 api_client_error_handler( | ||||
|                     state.clone(), | ||||
|                     merchant_key_store.clone(), | ||||
|                     business_profile.merchant_id.clone(), | ||||
|                     event_id.clone(), | ||||
|                     client_error, | ||||
|                     delivery_attempt, | ||||
|                 ) | ||||
|                 .await? | ||||
|             } | ||||
|             Ok(response) => { | ||||
|                 let status_code = response.status(); | ||||
|                 let _updated_event = update_event_in_storage( | ||||
| @ -1043,7 +1119,15 @@ async fn trigger_webhook_to_merchant( | ||||
|                 .attach_printable("`process_tracker` is unavailable in automatic retry flow")?; | ||||
|             match response { | ||||
|                 Err(client_error) => { | ||||
|                     api_client_error_handler(client_error, delivery_attempt); | ||||
|                     api_client_error_handler( | ||||
|                         state.clone(), | ||||
|                         merchant_key_store.clone(), | ||||
|                         business_profile.merchant_id.clone(), | ||||
|                         event_id.clone(), | ||||
|                         client_error, | ||||
|                         delivery_attempt, | ||||
|                     ) | ||||
|                     .await?; | ||||
|                     // Schedule a retry attempt for webhook delivery | ||||
|                     outgoing_webhook_retry::retry_webhook_delivery_task( | ||||
|                         &*state.store, | ||||
| @ -1095,10 +1179,41 @@ async fn trigger_webhook_to_merchant( | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         enums::WebhookDeliveryAttempt::ManualRetry => { | ||||
|             // Will be updated when manual retry is implemented | ||||
|             Err(errors::WebhooksFlowError::NotReceivedByMerchant)? | ||||
|         } | ||||
|         enums::WebhookDeliveryAttempt::ManualRetry => match response { | ||||
|             Err(client_error) => { | ||||
|                 api_client_error_handler( | ||||
|                     state.clone(), | ||||
|                     merchant_key_store.clone(), | ||||
|                     business_profile.merchant_id.clone(), | ||||
|                     event_id.clone(), | ||||
|                     client_error, | ||||
|                     delivery_attempt, | ||||
|                 ) | ||||
|                 .await? | ||||
|             } | ||||
|             Ok(response) => { | ||||
|                 let status_code = response.status(); | ||||
|                 let _updated_event = update_event_in_storage( | ||||
|                     state.clone(), | ||||
|                     merchant_key_store.clone(), | ||||
|                     business_profile.merchant_id.clone(), | ||||
|                     event_id.clone(), | ||||
|                     response, | ||||
|                 ) | ||||
|                 .await?; | ||||
|  | ||||
|                 if status_code.is_success() { | ||||
|                     increment_webhook_outgoing_received_count(business_profile.merchant_id.clone()); | ||||
|                 } else { | ||||
|                     error_response_handler( | ||||
|                         business_profile.merchant_id, | ||||
|                         delivery_attempt, | ||||
|                         status_code.as_u16(), | ||||
|                         "Ignoring error when sending webhook to merchant", | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
|  | ||||
| @ -1,19 +1,21 @@ | ||||
| use error_stack::ResultExt; | ||||
| use masking::PeekInterface; | ||||
| use router_env::{instrument, tracing}; | ||||
|  | ||||
| use crate::{ | ||||
|     core::errors::{self, RouterResponse, StorageErrorExt}, | ||||
|     routes::AppState, | ||||
|     services::ApplicationResponse, | ||||
|     types::{api, domain, transformers::ForeignTryFrom}, | ||||
|     types::{api, domain, storage, transformers::ForeignTryFrom}, | ||||
|     utils::{OptionExt, StringExt}, | ||||
| }; | ||||
|  | ||||
| const INITIAL_DELIVERY_ATTEMPTS_LIST_MAX_LIMIT: i64 = 100; | ||||
|  | ||||
| #[derive(Debug)] | ||||
| enum MerchantIdOrProfileId { | ||||
|     MerchantId(String), | ||||
|     ProfileId(String), | ||||
| enum MerchantAccountOrBusinessProfile { | ||||
|     MerchantAccount(domain::MerchantAccount), | ||||
|     BusinessProfile(storage::BusinessProfile), | ||||
| } | ||||
|  | ||||
| #[instrument(skip(state))] | ||||
| @ -27,22 +29,22 @@ pub async fn list_initial_delivery_attempts( | ||||
|  | ||||
|     let store = state.store.as_ref(); | ||||
|  | ||||
|     let (identifier, key_store) = | ||||
|     let (account, key_store) = | ||||
|         determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; | ||||
|  | ||||
|     let events = match constraints { | ||||
|         api_models::webhook_events::EventListConstraintsInternal::ObjectIdFilter { object_id } => { | ||||
|             match identifier { | ||||
|                 MerchantIdOrProfileId::MerchantId(merchant_id) => store | ||||
|             match account { | ||||
|                 MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store | ||||
|                 .list_initial_events_by_merchant_id_primary_object_id( | ||||
|                     &merchant_id, | ||||
|                     &merchant_account.merchant_id, | ||||
|                     &object_id, | ||||
|                     &key_store, | ||||
|                 ) | ||||
|                 .await, | ||||
|                 MerchantIdOrProfileId::ProfileId(profile_id) => store | ||||
|                 MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store | ||||
|                 .list_initial_events_by_profile_id_primary_object_id( | ||||
|                     &profile_id, | ||||
|                     &business_profile.profile_id, | ||||
|                     &object_id, | ||||
|                     &key_store, | ||||
|                 ) | ||||
| @ -69,10 +71,10 @@ pub async fn list_initial_delivery_attempts( | ||||
|                 _ => None, | ||||
|             }; | ||||
|  | ||||
|             match identifier { | ||||
|                 MerchantIdOrProfileId::MerchantId(merchant_id) => store | ||||
|             match account { | ||||
|                 MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => store | ||||
|                 .list_initial_events_by_merchant_id_constraints( | ||||
|                     &merchant_id, | ||||
|                     &merchant_account.merchant_id, | ||||
|                     created_after, | ||||
|                     created_before, | ||||
|                     limit, | ||||
| @ -80,9 +82,9 @@ pub async fn list_initial_delivery_attempts( | ||||
|                     &key_store, | ||||
|                 ) | ||||
|                 .await, | ||||
|                 MerchantIdOrProfileId::ProfileId(profile_id) => store | ||||
|                 MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => store | ||||
|                 .list_initial_events_by_profile_id_constraints( | ||||
|                     &profile_id, | ||||
|                     &business_profile.profile_id, | ||||
|                     created_after, | ||||
|                     created_before, | ||||
|                     limit, | ||||
| @ -112,23 +114,23 @@ pub async fn list_delivery_attempts( | ||||
| ) -> RouterResponse<Vec<api::webhook_events::EventRetrieveResponse>> { | ||||
|     let store = state.store.as_ref(); | ||||
|  | ||||
|     let (identifier, key_store) = | ||||
|     let (account, key_store) = | ||||
|         determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; | ||||
|  | ||||
|     let events = match identifier { | ||||
|         MerchantIdOrProfileId::MerchantId(merchant_id) => { | ||||
|     let events = match account { | ||||
|         MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account) => { | ||||
|             store | ||||
|                 .list_events_by_merchant_id_initial_attempt_id( | ||||
|                     &merchant_id, | ||||
|                     &merchant_account.merchant_id, | ||||
|                     &initial_attempt_id, | ||||
|                     &key_store, | ||||
|                 ) | ||||
|                 .await | ||||
|         } | ||||
|         MerchantIdOrProfileId::ProfileId(profile_id) => { | ||||
|         MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => { | ||||
|             store | ||||
|                 .list_events_by_profile_id_initial_attempt_id( | ||||
|                     &profile_id, | ||||
|                     &business_profile.profile_id, | ||||
|                     &initial_attempt_id, | ||||
|                     &key_store, | ||||
|                 ) | ||||
| @ -153,10 +155,108 @@ pub async fn list_delivery_attempts( | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[instrument(skip(state))] | ||||
| pub async fn retry_delivery_attempt( | ||||
|     state: AppState, | ||||
|     merchant_id_or_profile_id: String, | ||||
|     event_id: String, | ||||
| ) -> RouterResponse<api::webhook_events::EventRetrieveResponse> { | ||||
|     let store = state.store.as_ref(); | ||||
|  | ||||
|     let (account, key_store) = | ||||
|         determine_identifier_and_get_key_store(state.clone(), merchant_id_or_profile_id).await?; | ||||
|  | ||||
|     let event_to_retry = store | ||||
|         .find_event_by_merchant_id_event_id(&key_store.merchant_id, &event_id, &key_store) | ||||
|         .await | ||||
|         .to_not_found_response(errors::ApiErrorResponse::EventNotFound)?; | ||||
|  | ||||
|     let business_profile = match account { | ||||
|         MerchantAccountOrBusinessProfile::MerchantAccount(_) => { | ||||
|             let business_profile_id = event_to_retry | ||||
|                 .business_profile_id | ||||
|                 .get_required_value("business_profile_id") | ||||
|                 .change_context(errors::ApiErrorResponse::InternalServerError) | ||||
|                 .attach_printable("Failed to read business profile ID from event to retry")?; | ||||
|             store | ||||
|                 .find_business_profile_by_profile_id(&business_profile_id) | ||||
|                 .await | ||||
|                 .change_context(errors::ApiErrorResponse::InternalServerError) | ||||
|                 .attach_printable("Failed to find business profile") | ||||
|         } | ||||
|         MerchantAccountOrBusinessProfile::BusinessProfile(business_profile) => Ok(business_profile), | ||||
|     }?; | ||||
|  | ||||
|     let delivery_attempt = storage::enums::WebhookDeliveryAttempt::ManualRetry; | ||||
|     let new_event_id = super::utils::generate_event_id(); | ||||
|     let idempotent_event_id = super::utils::get_idempotent_event_id( | ||||
|         &event_to_retry.primary_object_id, | ||||
|         event_to_retry.event_type, | ||||
|         delivery_attempt, | ||||
|     ); | ||||
|  | ||||
|     let now = common_utils::date_time::now(); | ||||
|     let new_event = domain::Event { | ||||
|         event_id: new_event_id.clone(), | ||||
|         event_type: event_to_retry.event_type, | ||||
|         event_class: event_to_retry.event_class, | ||||
|         is_webhook_notified: false, | ||||
|         primary_object_id: event_to_retry.primary_object_id, | ||||
|         primary_object_type: event_to_retry.primary_object_type, | ||||
|         created_at: now, | ||||
|         merchant_id: Some(business_profile.merchant_id.clone()), | ||||
|         business_profile_id: Some(business_profile.profile_id.clone()), | ||||
|         primary_object_created_at: event_to_retry.primary_object_created_at, | ||||
|         idempotent_event_id: Some(idempotent_event_id), | ||||
|         initial_attempt_id: event_to_retry.initial_attempt_id, | ||||
|         request: event_to_retry.request, | ||||
|         response: None, | ||||
|         delivery_attempt: Some(delivery_attempt), | ||||
|     }; | ||||
|  | ||||
|     let event = store | ||||
|         .insert_event(new_event, &key_store) | ||||
|         .await | ||||
|         .change_context(errors::ApiErrorResponse::InternalServerError) | ||||
|         .attach_printable("Failed to insert event")?; | ||||
|  | ||||
|     // We only allow retrying deliveries for events with `request` populated. | ||||
|     let request_content = event | ||||
|         .request | ||||
|         .as_ref() | ||||
|         .get_required_value("request") | ||||
|         .change_context(errors::ApiErrorResponse::InternalServerError)? | ||||
|         .peek() | ||||
|         .parse_struct("OutgoingWebhookRequestContent") | ||||
|         .change_context(errors::ApiErrorResponse::InternalServerError) | ||||
|         .attach_printable("Failed to parse webhook event request information")?; | ||||
|  | ||||
|     super::trigger_webhook_and_raise_event( | ||||
|         state.clone(), | ||||
|         business_profile, | ||||
|         &key_store, | ||||
|         event, | ||||
|         request_content, | ||||
|         delivery_attempt, | ||||
|         None, | ||||
|         None, | ||||
|     ) | ||||
|     .await; | ||||
|  | ||||
|     let updated_event = store | ||||
|         .find_event_by_merchant_id_event_id(&key_store.merchant_id, &new_event_id, &key_store) | ||||
|         .await | ||||
|         .to_not_found_response(errors::ApiErrorResponse::EventNotFound)?; | ||||
|  | ||||
|     Ok(ApplicationResponse::Json( | ||||
|         api::webhook_events::EventRetrieveResponse::try_from(updated_event)?, | ||||
|     )) | ||||
| } | ||||
|  | ||||
| async fn determine_identifier_and_get_key_store( | ||||
|     state: AppState, | ||||
|     merchant_id_or_profile_id: String, | ||||
| ) -> errors::RouterResult<(MerchantIdOrProfileId, domain::MerchantKeyStore)> { | ||||
| ) -> errors::RouterResult<(MerchantAccountOrBusinessProfile, domain::MerchantKeyStore)> { | ||||
|     let store = state.store.as_ref(); | ||||
|     match store | ||||
|         .get_merchant_key_store_by_merchant_id( | ||||
| @ -165,13 +265,25 @@ async fn determine_identifier_and_get_key_store( | ||||
|         ) | ||||
|         .await | ||||
|     { | ||||
|         // Valid merchant ID | ||||
|         Ok(key_store) => Ok(( | ||||
|             MerchantIdOrProfileId::MerchantId(merchant_id_or_profile_id), | ||||
|             key_store, | ||||
|         )), | ||||
|         // Since a merchant key store was found with `merchant_id` = `merchant_id_or_profile_id`, | ||||
|         // `merchant_id_or_profile_id` is a valid merchant ID. | ||||
|         // Find a merchant account having `merchant_id` = `merchant_id_or_profile_id`. | ||||
|         Ok(key_store) => { | ||||
|             let merchant_account = store | ||||
|                 .find_merchant_account_by_merchant_id(&merchant_id_or_profile_id, &key_store) | ||||
|                 .await | ||||
|                 .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; | ||||
|  | ||||
|         // Invalid merchant ID, check if we can find a business profile with the identifier | ||||
|             Ok(( | ||||
|                 MerchantAccountOrBusinessProfile::MerchantAccount(merchant_account), | ||||
|                 key_store, | ||||
|             )) | ||||
|         } | ||||
|  | ||||
|         // Since no merchant key store was found with `merchant_id` = `merchant_id_or_profile_id`, | ||||
|         // `merchant_id_or_profile_id` is not a valid merchant ID. | ||||
|         // Assuming that `merchant_id_or_profile_id` is a business profile ID, try to find a | ||||
|         // business profile having `profile_id` = `merchant_id_or_profile_id`. | ||||
|         Err(error) if error.current_context().is_db_not_found() => { | ||||
|             router_env::logger::debug!( | ||||
|                 ?error, | ||||
| @ -195,7 +307,7 @@ async fn determine_identifier_and_get_key_store( | ||||
|                 .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; | ||||
|  | ||||
|             Ok(( | ||||
|                 MerchantIdOrProfileId::ProfileId(business_profile.profile_id), | ||||
|                 MerchantAccountOrBusinessProfile::BusinessProfile(business_profile), | ||||
|                 key_store, | ||||
|             )) | ||||
|         } | ||||
|  | ||||
| @ -1247,8 +1247,15 @@ impl WebhookEvents { | ||||
|             .app_data(web::Data::new(config)) | ||||
|             .service(web::resource("").route(web::get().to(list_initial_webhook_delivery_attempts))) | ||||
|             .service( | ||||
|                 web::resource("/{event_id}/attempts") | ||||
|                     .route(web::get().to(list_webhook_delivery_attempts)), | ||||
|                 web::scope("/{event_id}") | ||||
|                     .service( | ||||
|                         web::resource("attempts") | ||||
|                             .route(web::get().to(list_webhook_delivery_attempts)), | ||||
|                     ) | ||||
|                     .service( | ||||
|                         web::resource("retry") | ||||
|                             .route(web::post().to(retry_webhook_delivery_attempt)), | ||||
|                     ), | ||||
|             ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -137,7 +137,8 @@ impl From<Flow> for ApiIdentifier { | ||||
|             Flow::FrmFulfillment | ||||
|             | Flow::IncomingWebhookReceive | ||||
|             | Flow::WebhookEventInitialDeliveryAttemptList | ||||
|             | Flow::WebhookEventDeliveryAttemptList => Self::Webhooks, | ||||
|             | Flow::WebhookEventDeliveryAttemptList | ||||
|             | Flow::WebhookEventDeliveryRetry => Self::Webhooks, | ||||
|  | ||||
|             Flow::ApiKeyCreate | ||||
|             | Flow::ApiKeyRetrieve | ||||
|  | ||||
| @ -7,6 +7,7 @@ use crate::{ | ||||
|     services::{api, authentication as auth, authorization::permissions::Permission}, | ||||
|     types::api::webhook_events::{ | ||||
|         EventListConstraints, EventListRequestInternal, WebhookDeliveryAttemptListRequestInternal, | ||||
|         WebhookDeliveryRetryRequestInternal, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| @ -89,3 +90,42 @@ pub async fn list_webhook_delivery_attempts( | ||||
|     ) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| #[instrument(skip_all, fields(flow = ?Flow::WebhookEventDeliveryRetry))] | ||||
| pub async fn retry_webhook_delivery_attempt( | ||||
|     state: web::Data<AppState>, | ||||
|     req: HttpRequest, | ||||
|     path: web::Path<(String, String)>, | ||||
| ) -> impl Responder { | ||||
|     let flow = Flow::WebhookEventDeliveryRetry; | ||||
|     let (merchant_id_or_profile_id, event_id) = path.into_inner(); | ||||
|  | ||||
|     let request_internal = WebhookDeliveryRetryRequestInternal { | ||||
|         merchant_id_or_profile_id: merchant_id_or_profile_id.clone(), | ||||
|         event_id, | ||||
|     }; | ||||
|  | ||||
|     api::server_wrap( | ||||
|         flow, | ||||
|         state, | ||||
|         &req, | ||||
|         request_internal, | ||||
|         |state, _, request_internal| { | ||||
|             webhook_events::retry_delivery_attempt( | ||||
|                 state, | ||||
|                 request_internal.merchant_id_or_profile_id, | ||||
|                 request_internal.event_id, | ||||
|             ) | ||||
|         }, | ||||
|         auth::auth_type( | ||||
|             &auth::AdminApiAuth, | ||||
|             &auth::JWTAuthMerchantOrProfileFromRoute { | ||||
|                 merchant_id_or_profile_id, | ||||
|                 required_permission: Permission::WebhookEventWrite, | ||||
|             }, | ||||
|             req.headers(), | ||||
|         ), | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     ) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| @ -75,12 +75,13 @@ pub static USERS_MANAGE: [Permission; 2] = | ||||
|  | ||||
| pub static MERCHANT_DETAILS_VIEW: [Permission; 1] = [Permission::MerchantAccountRead]; | ||||
|  | ||||
| pub static MERCHANT_DETAILS_MANAGE: [Permission; 5] = [ | ||||
| pub static MERCHANT_DETAILS_MANAGE: [Permission; 6] = [ | ||||
|     Permission::MerchantAccountWrite, | ||||
|     Permission::ApiKeyRead, | ||||
|     Permission::ApiKeyWrite, | ||||
|     Permission::MerchantAccountRead, | ||||
|     Permission::WebhookEventRead, | ||||
|     Permission::WebhookEventWrite, | ||||
| ]; | ||||
|  | ||||
| pub static ORGANIZATION_MANAGE: [Permission; 2] = [ | ||||
|  | ||||
| @ -31,6 +31,7 @@ pub enum Permission { | ||||
|     UsersWrite, | ||||
|     MerchantAccountCreate, | ||||
|     WebhookEventRead, | ||||
|     WebhookEventWrite, | ||||
|     PayoutRead, | ||||
|     PayoutWrite, | ||||
| } | ||||
| @ -71,6 +72,7 @@ impl Permission { | ||||
|             Self::UsersWrite => "Invite users, assign and update roles", | ||||
|             Self::MerchantAccountCreate => "Create merchant account", | ||||
|             Self::WebhookEventRead => "View webhook events", | ||||
|             Self::WebhookEventWrite => "Trigger retries for webhook events", | ||||
|             Self::PayoutRead => "View all payouts", | ||||
|             Self::PayoutWrite => "Create payout, download payout data", | ||||
|         } | ||||
|  | ||||
| @ -2,4 +2,5 @@ pub use api_models::webhook_events::{ | ||||
|     EventListConstraints, EventListConstraintsInternal, EventListItemResponse, | ||||
|     EventListRequestInternal, EventRetrieveResponse, OutgoingWebhookRequestContent, | ||||
|     OutgoingWebhookResponseContent, WebhookDeliveryAttemptListRequestInternal, | ||||
|     WebhookDeliveryRetryRequestInternal, | ||||
| }; | ||||
|  | ||||
| @ -44,6 +44,7 @@ impl From<Permission> for user_role_api::Permission { | ||||
|             Permission::UsersWrite => Self::UsersWrite, | ||||
|             Permission::MerchantAccountCreate => Self::MerchantAccountCreate, | ||||
|             Permission::WebhookEventRead => Self::WebhookEventRead, | ||||
|             Permission::WebhookEventWrite => Self::WebhookEventWrite, | ||||
|             Permission::PayoutRead => Self::PayoutRead, | ||||
|             Permission::PayoutWrite => Self::PayoutWrite, | ||||
|         } | ||||
|  | ||||
| @ -120,7 +120,7 @@ impl ProcessTrackerWorkflow<AppState> for OutgoingWebhookRetryWorkflow { | ||||
|                     &key_store, | ||||
|                     event, | ||||
|                     request_content, | ||||
|                     storage::enums::WebhookDeliveryAttempt::AutomaticRetry, | ||||
|                     delivery_attempt, | ||||
|                     None, | ||||
|                     Some(process), | ||||
|                 ) | ||||
| @ -172,7 +172,7 @@ impl ProcessTrackerWorkflow<AppState> for OutgoingWebhookRetryWorkflow { | ||||
|                             &key_store, | ||||
|                             event, | ||||
|                             request_content, | ||||
|                             storage::enums::WebhookDeliveryAttempt::AutomaticRetry, | ||||
|                             delivery_attempt, | ||||
|                             Some(content), | ||||
|                             Some(process), | ||||
|                         ) | ||||
|  | ||||
| @ -398,6 +398,8 @@ pub enum Flow { | ||||
|     WebhookEventInitialDeliveryAttemptList, | ||||
|     /// List delivery attempts for a webhook event | ||||
|     WebhookEventDeliveryAttemptList, | ||||
|     /// Manually retry the delivery for a webhook event | ||||
|     WebhookEventDeliveryRetry, | ||||
| } | ||||
|  | ||||
| /// | ||||
|  | ||||
| @ -4469,6 +4469,53 @@ | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/events/{merchant_id_or_profile_id}/{event_id}/retry": { | ||||
|       "post": { | ||||
|         "tags": [ | ||||
|           "Event" | ||||
|         ], | ||||
|         "summary": "Events - Manual Retry", | ||||
|         "description": "Events - Manual Retry\n\nManually retry the delivery of the specified Event.", | ||||
|         "operationId": "Manually retry the delivery of an Event", | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "merchant_id_or_profile_id", | ||||
|             "in": "path", | ||||
|             "description": "The unique identifier for the Merchant Account or Business Profile", | ||||
|             "required": true, | ||||
|             "schema": { | ||||
|               "type": "string" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "event_id", | ||||
|             "in": "path", | ||||
|             "description": "The unique identifier for the Event", | ||||
|             "required": true, | ||||
|             "schema": { | ||||
|               "type": "string" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "The delivery of the Event was attempted. Check the `response` field in the response payload to identify the status of the delivery attempt.", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/EventRetrieveResponse" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "security": [ | ||||
|           { | ||||
|             "admin_api_key": [] | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "components": { | ||||
| @ -11661,15 +11708,11 @@ | ||||
|       "OutgoingWebhookResponseContent": { | ||||
|         "type": "object", | ||||
|         "description": "The response information (headers, body and status code) received for the webhook sent.", | ||||
|         "required": [ | ||||
|           "body", | ||||
|           "headers", | ||||
|           "status_code" | ||||
|         ], | ||||
|         "properties": { | ||||
|           "body": { | ||||
|             "type": "string", | ||||
|             "description": "The response body received for the webhook sent." | ||||
|             "description": "The response body received for the webhook sent.", | ||||
|             "nullable": true | ||||
|           }, | ||||
|           "headers": { | ||||
|             "type": "array", | ||||
| @ -11696,14 +11739,22 @@ | ||||
|                 "content-length", | ||||
|                 "1024" | ||||
|               ] | ||||
|             ] | ||||
|             ], | ||||
|             "nullable": true | ||||
|           }, | ||||
|           "status_code": { | ||||
|             "type": "integer", | ||||
|             "format": "int32", | ||||
|             "description": "The HTTP status code for the webhook sent.", | ||||
|             "example": 200, | ||||
|             "nullable": true, | ||||
|             "minimum": 0 | ||||
|           }, | ||||
|           "error_message": { | ||||
|             "type": "string", | ||||
|             "description": "Error message in case any error occurred when trying to deliver the webhook.", | ||||
|             "example": 200, | ||||
|             "nullable": true | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Sanchith Hegde
					Sanchith Hegde