pub mod types; pub mod utils; use std::{str::FromStr, time::Instant}; use actix_web::FromRequest; use api_models::{ payments::HeaderPayload, webhooks::{self, WebhookResponseTracker}, }; use common_utils::{errors::ReportSwitchExt, events::ApiEventsType, request::RequestContent}; use error_stack::{report, IntoReport, ResultExt}; use masking::ExposeInterface; use router_env::{instrument, tracing, tracing_actix_web::RequestId}; use super::{errors::StorageErrorExt, metrics}; #[cfg(feature = "stripe")] use crate::compatibility::stripe::webhooks as stripe_webhooks; use crate::{ consts, core::{ api_locking, errors::{self, ConnectorErrorExt, CustomResult, RouterResponse}, payment_methods::PaymentMethodRetrieve, payments, refunds, }, db::StorageInterface, events::{ api_logs::ApiEvent, outgoing_webhook_logs::{OutgoingWebhookEvent, OutgoingWebhookEventMetric}, }, logger, routes::{app::AppStateInfo, lock_utils, metrics::request::add_attributes, AppState}, services::{self, authentication as auth}, types::{ api::{self, mandates::MandateResponseExt}, domain, storage::{self, enums}, transformers::{ForeignInto, ForeignTryInto}, }, utils::{self as helper_utils, generate_id, OptionExt, ValueExt}, }; const OUTGOING_WEBHOOK_TIMEOUT_SECS: u64 = 5; const MERCHANT_ID: &str = "merchant_id"; pub async fn payments_incoming_webhook_flow< W: types::OutgoingWebhookType, Ctx: PaymentMethodRetrieve, >( state: AppState, merchant_account: domain::MerchantAccount, business_profile: diesel_models::business_profile::BusinessProfile, key_store: domain::MerchantKeyStore, webhook_details: api::IncomingWebhookDetails, source_verified: bool, ) -> CustomResult { let consume_or_trigger_flow = if source_verified { payments::CallConnectorAction::HandleResponse(webhook_details.resource_object) } else { payments::CallConnectorAction::Trigger }; let payments_response = match webhook_details.object_reference_id { api_models::webhooks::ObjectReferenceId::PaymentId(id) => { let payment_id = get_payment_id( state.store.as_ref(), &id, merchant_account.merchant_id.as_str(), merchant_account.storage_scheme, ) .await?; let lock_action = api_locking::LockAction::Hold { input: super::api_locking::LockingInput { unique_locking_key: payment_id, api_identifier: lock_utils::ApiIdentifier::Payments, override_lock_retries: None, }, }; lock_action .clone() .perform_locking_action(&state, merchant_account.merchant_id.to_string()) .await?; let response = Box::pin(payments::payments_core::< api::PSync, api::PaymentsResponse, _, _, _, Ctx, >( state.clone(), merchant_account.clone(), key_store, payments::operations::PaymentStatus, api::PaymentsRetrieveRequest { resource_id: id, merchant_id: Some(merchant_account.merchant_id.clone()), force_sync: true, connector: None, param: None, merchant_connector_details: None, client_secret: None, expand_attempts: None, expand_captures: None, }, services::AuthFlow::Merchant, consume_or_trigger_flow, None, HeaderPayload::default(), )) .await; lock_action .free_lock_action(&state, merchant_account.merchant_id.to_owned()) .await?; match response { Ok(value) => value, Err(err) if matches!( err.current_context(), &errors::ApiErrorResponse::PaymentNotFound ) && state .conf .webhooks .ignore_error .payment_not_found .unwrap_or(true) => { metrics::WEBHOOK_PAYMENT_NOT_FOUND.add( &metrics::CONTEXT, 1, &[add_attributes( "merchant_id", merchant_account.merchant_id.clone(), )], ); return Ok(WebhookResponseTracker::NoEffect); } error @ Err(_) => error?, } } _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) .into_report() .attach_printable( "Did not get payment id as object reference id in webhook payments flow", )?, }; match payments_response { services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { let payment_id = payments_response .payment_id .clone() .get_required_value("payment_id") .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("payment id not received from payments core")?; let status = payments_response.status; let event_type: Option = payments_response.status.foreign_into(); // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook if let Some(outgoing_event_type) = event_type { create_event_and_trigger_outgoing_webhook::( state, merchant_account, business_profile, outgoing_event_type, enums::EventClass::Payments, None, payment_id.clone(), enums::EventObjectType::PaymentDetails, api::OutgoingWebhookContent::PaymentDetails(payments_response), ) .await?; }; let response = WebhookResponseTracker::Payment { payment_id, status }; Ok(response) } _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) .into_report() .attach_printable("received non-json response from payments core")?, } } #[instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn refunds_incoming_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, business_profile: diesel_models::business_profile::BusinessProfile, key_store: domain::MerchantKeyStore, webhook_details: api::IncomingWebhookDetails, connector_name: &str, source_verified: bool, event_type: api_models::webhooks::IncomingWebhookEvent, ) -> CustomResult { let db = &*state.store; //find refund by connector refund id let refund = match webhook_details.object_reference_id { api_models::webhooks::ObjectReferenceId::RefundId(refund_id_type) => match refund_id_type { api_models::webhooks::RefundIdType::RefundId(id) => db .find_refund_by_merchant_id_refund_id( &merchant_account.merchant_id, &id, merchant_account.storage_scheme, ) .await .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) .attach_printable_lazy(|| "Failed fetching the refund")?, api_models::webhooks::RefundIdType::ConnectorRefundId(id) => db .find_refund_by_merchant_id_connector_refund_id_connector( &merchant_account.merchant_id, &id, connector_name, merchant_account.storage_scheme, ) .await .change_context(errors::ApiErrorResponse::WebhookResourceNotFound) .attach_printable_lazy(|| "Failed fetching the refund")?, }, _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) .into_report() .attach_printable("received a non-refund id when processing refund webhooks")?, }; let refund_id = refund.refund_id.to_owned(); //if source verified then update refund status else trigger refund sync let updated_refund = if source_verified { let refund_update = storage::RefundUpdate::StatusUpdate { connector_refund_id: None, sent_to_gateway: true, refund_status: event_type .foreign_try_into() .into_report() .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("failed refund status mapping from event type")?, updated_by: merchant_account.storage_scheme.to_string(), }; db.update_refund( refund.to_owned(), refund_update, merchant_account.storage_scheme, ) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) .attach_printable_lazy(|| { format!( "Failed while updating refund: refund_id: {}", refund_id.to_owned() ) })? } else { refunds::refund_retrieve_core( state.clone(), merchant_account.clone(), key_store, api_models::refunds::RefundsRetrieveRequest { refund_id: refund_id.to_owned(), force_sync: Some(true), merchant_connector_details: None, }, ) .await .attach_printable_lazy(|| { format!( "Failed while updating refund: refund_id: {}", refund_id.to_owned() ) })? }; let event_type: Option = updated_refund.refund_status.foreign_into(); // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook if let Some(outgoing_event_type) = event_type { let refund_response: api_models::refunds::RefundResponse = updated_refund.clone().foreign_into(); create_event_and_trigger_outgoing_webhook::( state, merchant_account, business_profile, outgoing_event_type, enums::EventClass::Refunds, None, refund_id, enums::EventObjectType::RefundDetails, api::OutgoingWebhookContent::RefundDetails(refund_response), ) .await?; } Ok(WebhookResponseTracker::Refund { payment_id: updated_refund.payment_id, refund_id: updated_refund.refund_id, status: updated_refund.refund_status, }) } pub async fn get_payment_attempt_from_object_reference_id( state: &AppState, object_reference_id: api_models::webhooks::ObjectReferenceId, merchant_account: &domain::MerchantAccount, ) -> CustomResult { let db = &*state.store; match object_reference_id { api::ObjectReferenceId::PaymentId(api::PaymentIdType::ConnectorTransactionId(ref id)) => db .find_payment_attempt_by_merchant_id_connector_txn_id( &merchant_account.merchant_id, id, merchant_account.storage_scheme, ) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), api::ObjectReferenceId::PaymentId(api::PaymentIdType::PaymentAttemptId(ref id)) => db .find_payment_attempt_by_attempt_id_merchant_id( id, &merchant_account.merchant_id, merchant_account.storage_scheme, ) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), api::ObjectReferenceId::PaymentId(api::PaymentIdType::PreprocessingId(ref id)) => db .find_payment_attempt_by_preprocessing_id_merchant_id( id, &merchant_account.merchant_id, merchant_account.storage_scheme, ) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound), _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) .into_report() .attach_printable("received a non-payment id for retrieving payment")?, } } pub async fn get_or_update_dispute_object( state: AppState, option_dispute: Option, dispute_details: api::disputes::DisputePayload, merchant_id: &str, payment_attempt: &data_models::payments::payment_attempt::PaymentAttempt, event_type: api_models::webhooks::IncomingWebhookEvent, connector_name: &str, ) -> CustomResult { let db = &*state.store; match option_dispute { None => { metrics::INCOMING_DISPUTE_WEBHOOK_NEW_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]); let dispute_id = generate_id(consts::ID_LENGTH, "dp"); let new_dispute = diesel_models::dispute::DisputeNew { dispute_id, amount: dispute_details.amount, currency: dispute_details.currency, dispute_stage: dispute_details.dispute_stage, dispute_status: event_type .foreign_try_into() .into_report() .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("event type to dispute status mapping failed")?, payment_id: payment_attempt.payment_id.to_owned(), connector: connector_name.to_owned(), attempt_id: payment_attempt.attempt_id.to_owned(), merchant_id: merchant_id.to_owned(), connector_status: dispute_details.connector_status, connector_dispute_id: dispute_details.connector_dispute_id, connector_reason: dispute_details.connector_reason, connector_reason_code: dispute_details.connector_reason_code, challenge_required_by: dispute_details.challenge_required_by, connector_created_at: dispute_details.created_at, connector_updated_at: dispute_details.updated_at, profile_id: None, evidence: None, merchant_connector_id: payment_attempt.merchant_connector_id.clone(), }; state .store .insert_dispute(new_dispute.clone()) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) } Some(dispute) => { logger::info!("Dispute Already exists, Updating the dispute details"); metrics::INCOMING_DISPUTE_WEBHOOK_UPDATE_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]); let dispute_status: diesel_models::enums::DisputeStatus = event_type .foreign_try_into() .into_report() .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("event type to dispute state conversion failure")?; crate::core::utils::validate_dispute_stage_and_dispute_status( dispute.dispute_stage, dispute.dispute_status, dispute_details.dispute_stage, dispute_status, ) .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("dispute stage and status validation failed")?; let update_dispute = diesel_models::dispute::DisputeUpdate::Update { dispute_stage: dispute_details.dispute_stage, dispute_status, connector_status: dispute_details.connector_status, connector_reason: dispute_details.connector_reason, connector_reason_code: dispute_details.connector_reason_code, challenge_required_by: dispute_details.challenge_required_by, connector_updated_at: dispute_details.updated_at, }; db.update_dispute(dispute, update_dispute) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound) } } } pub async fn mandates_incoming_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, business_profile: diesel_models::business_profile::BusinessProfile, webhook_details: api::IncomingWebhookDetails, source_verified: bool, event_type: api_models::webhooks::IncomingWebhookEvent, ) -> CustomResult { if source_verified { let db = &*state.store; let mandate = match webhook_details.object_reference_id { webhooks::ObjectReferenceId::MandateId(webhooks::MandateIdType::MandateId( mandate_id, )) => db .find_mandate_by_merchant_id_mandate_id( &merchant_account.merchant_id, mandate_id.as_str(), ) .await .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?, webhooks::ObjectReferenceId::MandateId( webhooks::MandateIdType::ConnectorMandateId(connector_mandate_id), ) => db .find_mandate_by_merchant_id_connector_mandate_id( &merchant_account.merchant_id, connector_mandate_id.as_str(), ) .await .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?, _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) .into_report() .attach_printable("received a non-mandate id for retrieving mandate")?, }; let mandate_status = event_type .foreign_try_into() .into_report() .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("event type to mandate status mapping failed")?; let updated_mandate = db .update_mandate_by_merchant_id_mandate_id( &merchant_account.merchant_id, &mandate.mandate_id, storage::MandateUpdate::StatusUpdate { mandate_status }, ) .await .to_not_found_response(errors::ApiErrorResponse::MandateNotFound)?; let mandates_response = Box::new( api::mandates::MandateResponse::from_db_mandate(&state, updated_mandate.clone()) .await?, ); let event_type: Option = updated_mandate.mandate_status.foreign_into(); if let Some(outgoing_event_type) = event_type { create_event_and_trigger_outgoing_webhook::( state, merchant_account, business_profile, outgoing_event_type, enums::EventClass::Mandates, None, updated_mandate.mandate_id.clone(), enums::EventObjectType::MandateDetails, api::OutgoingWebhookContent::MandateDetails(mandates_response), ) .await?; } Ok(WebhookResponseTracker::Mandate { mandate_id: updated_mandate.mandate_id, status: updated_mandate.mandate_status, }) } else { logger::error!("Webhook source verification failed for mandates webhook flow"); Err(errors::ApiErrorResponse::WebhookAuthenticationFailed).into_report() } } #[allow(clippy::too_many_arguments)] #[instrument(skip_all)] pub async fn disputes_incoming_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, business_profile: diesel_models::business_profile::BusinessProfile, webhook_details: api::IncomingWebhookDetails, source_verified: bool, connector: &(dyn api::Connector + Sync), request_details: &api::IncomingWebhookRequestDetails<'_>, event_type: api_models::webhooks::IncomingWebhookEvent, ) -> CustomResult { metrics::INCOMING_DISPUTE_WEBHOOK_METRIC.add(&metrics::CONTEXT, 1, &[]); if source_verified { let db = &*state.store; let dispute_details = connector.get_dispute_details(request_details).switch()?; let payment_attempt = get_payment_attempt_from_object_reference_id( &state, webhook_details.object_reference_id, &merchant_account, ) .await?; let option_dispute = db .find_by_merchant_id_payment_id_connector_dispute_id( &merchant_account.merchant_id, &payment_attempt.payment_id, &dispute_details.connector_dispute_id, ) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound)?; let dispute_object = get_or_update_dispute_object( state.clone(), option_dispute, dispute_details, &merchant_account.merchant_id, &payment_attempt, event_type, connector.id(), ) .await?; let disputes_response = Box::new(dispute_object.clone().foreign_into()); let event_type: enums::EventType = dispute_object.dispute_status.foreign_into(); create_event_and_trigger_outgoing_webhook::( state, merchant_account, business_profile, event_type, enums::EventClass::Disputes, None, dispute_object.dispute_id.clone(), enums::EventObjectType::DisputeDetails, api::OutgoingWebhookContent::DisputeDetails(disputes_response), ) .await?; metrics::INCOMING_DISPUTE_WEBHOOK_MERCHANT_NOTIFIED_METRIC.add(&metrics::CONTEXT, 1, &[]); Ok(WebhookResponseTracker::Dispute { dispute_id: dispute_object.dispute_id, payment_id: dispute_object.payment_id, status: dispute_object.dispute_status, }) } else { metrics::INCOMING_DISPUTE_WEBHOOK_SIGNATURE_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]); Err(errors::ApiErrorResponse::WebhookAuthenticationFailed).into_report() } } async fn bank_transfer_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, business_profile: diesel_models::business_profile::BusinessProfile, key_store: domain::MerchantKeyStore, webhook_details: api::IncomingWebhookDetails, source_verified: bool, ) -> CustomResult { let response = if source_verified { let payment_attempt = get_payment_attempt_from_object_reference_id( &state, webhook_details.object_reference_id, &merchant_account, ) .await?; let payment_id = payment_attempt.payment_id; let request = api::PaymentsRequest { payment_id: Some(api_models::payments::PaymentIdType::PaymentIntentId( payment_id, )), payment_token: payment_attempt.payment_token, ..Default::default() }; Box::pin(payments::payments_core::< api::Authorize, api::PaymentsResponse, _, _, _, Ctx, >( state.clone(), merchant_account.to_owned(), key_store, payments::PaymentConfirm, request, services::api::AuthFlow::Merchant, payments::CallConnectorAction::Trigger, None, HeaderPayload::default(), )) .await } else { Err(report!( errors::ApiErrorResponse::WebhookAuthenticationFailed )) }; match response? { services::ApplicationResponse::JsonWithHeaders((payments_response, _)) => { let payment_id = payments_response .payment_id .clone() .get_required_value("payment_id") .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("did not receive payment id from payments core response")?; let event_type: Option = payments_response.status.foreign_into(); let status = payments_response.status; // If event is NOT an UnsupportedEvent, trigger Outgoing Webhook if let Some(outgoing_event_type) = event_type { create_event_and_trigger_outgoing_webhook::( state, merchant_account, business_profile, outgoing_event_type, enums::EventClass::Payments, None, payment_id.clone(), enums::EventObjectType::PaymentDetails, api::OutgoingWebhookContent::PaymentDetails(payments_response), ) .await?; } Ok(WebhookResponseTracker::Payment { payment_id, status }) } _ => Err(errors::ApiErrorResponse::WebhookProcessingFailure) .into_report() .attach_printable("received non-json response from payments core")?, } } #[allow(clippy::too_many_arguments)] #[instrument(skip_all)] pub async fn create_event_and_trigger_appropriate_outgoing_webhook( state: AppState, merchant_account: domain::MerchantAccount, business_profile: diesel_models::business_profile::BusinessProfile, event_type: enums::EventType, event_class: enums::EventClass, intent_reference_id: Option, primary_object_id: String, primary_object_type: enums::EventObjectType, content: api::OutgoingWebhookContent, ) -> CustomResult<(), errors::ApiErrorResponse> { match merchant_account.get_compatible_connector() { #[cfg(feature = "stripe")] Some(api_models::enums::Connector::Stripe) => { create_event_and_trigger_outgoing_webhook::( state.clone(), merchant_account, business_profile, event_type, event_class, intent_reference_id, primary_object_id, primary_object_type, content, ) .await } _ => { create_event_and_trigger_outgoing_webhook::( state.clone(), merchant_account, business_profile, event_type, event_class, intent_reference_id, primary_object_id, primary_object_type, content, ) .await } } } #[allow(clippy::too_many_arguments)] #[instrument(skip_all)] pub async fn create_event_and_trigger_outgoing_webhook( state: AppState, merchant_account: domain::MerchantAccount, business_profile: diesel_models::business_profile::BusinessProfile, event_type: enums::EventType, event_class: enums::EventClass, intent_reference_id: Option, primary_object_id: String, primary_object_type: enums::EventObjectType, content: api::OutgoingWebhookContent, ) -> CustomResult<(), errors::ApiErrorResponse> { let event_id = format!("{primary_object_id}_{}", event_type); let new_event = storage::EventNew { event_id: event_id.clone(), event_type, event_class, is_webhook_notified: false, intent_reference_id, primary_object_id, primary_object_type, }; let event_insert_result = state.store.insert_event(new_event).await; let event = match event_insert_result { Ok(event) => Ok(event), Err(error) => { if error.current_context().is_db_unique_violation() { logger::info!("Merchant already notified about the event {event_id}"); return Ok(()); } else { logger::error!(event_insertion_failure=?error); Err(error .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("Failed to insert event in events table")) } } }?; if state.conf.webhooks.outgoing_enabled { let outgoing_webhook = api::OutgoingWebhook { merchant_id: merchant_account.merchant_id.clone(), event_id: event.event_id.clone(), event_type: event.event_type, content: content.clone(), timestamp: event.created_at, }; let state_clone = state.clone(); // Using a tokio spawn here and not arbiter because not all caller of this function // may have an actix arbiter tokio::spawn(async move { let mut error = None; let result = trigger_webhook_to_merchant::(business_profile, outgoing_webhook, state).await; if let Err(e) = result { error.replace( serde_json::to_value(e.current_context()) .into_report() .attach_printable("Failed to serialize json error response") .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .ok() .into(), ); logger::error!(?e); } let outgoing_webhook_event_type = content.get_outgoing_webhook_event_type(); let webhook_event = OutgoingWebhookEvent::new( merchant_account.merchant_id.clone(), event.event_id.clone(), event_type, outgoing_webhook_event_type, error.is_some(), error, ); match webhook_event.clone().try_into() { Ok(event) => { state_clone.event_handler().log_event(event); } Err(err) => { logger::error!(error=?err, event=?webhook_event, "Error Logging Outgoing Webhook Event"); } } }); } Ok(()) } pub async fn trigger_webhook_to_merchant( business_profile: diesel_models::business_profile::BusinessProfile, webhook: api::OutgoingWebhook, state: AppState, ) -> CustomResult<(), errors::WebhooksFlowError> { let webhook_details_json = business_profile .webhook_details .get_required_value("webhook_details") .change_context(errors::WebhooksFlowError::MerchantWebhookDetailsNotFound)?; let webhook_details: api::WebhookDetails = webhook_details_json .parse_value("WebhookDetails") .change_context(errors::WebhooksFlowError::MerchantWebhookDetailsNotFound)?; let webhook_url = webhook_details .webhook_url .get_required_value("webhook_url") .change_context(errors::WebhooksFlowError::MerchantWebhookURLNotConfigured) .map(ExposeInterface::expose)?; let outgoing_webhook_event_id = webhook.event_id.clone(); let transformed_outgoing_webhook = W::from(webhook); let outgoing_webhooks_signature = transformed_outgoing_webhook .get_outgoing_webhooks_signature(business_profile.payment_response_hash_key.clone())?; let mut header = vec![( reqwest::header::CONTENT_TYPE.to_string(), "application/json".into(), )]; if let Some(signature) = outgoing_webhooks_signature { W::add_webhook_header(&mut header, signature) } let request = services::RequestBuilder::new() .method(services::Method::Post) .url(&webhook_url) .attach_default_headers() .headers(header) .set_body(RequestContent::Json(Box::new(transformed_outgoing_webhook))) .build(); let response = state .api_client .send_request(&state, request, Some(OUTGOING_WEBHOOK_TIMEOUT_SECS), false) .await; metrics::WEBHOOK_OUTGOING_COUNT.add( &metrics::CONTEXT, 1, &[metrics::KeyValue::new( MERCHANT_ID, business_profile.merchant_id.clone(), )], ); logger::debug!(outgoing_webhook_response=?response); match response { Err(e) => { // [#217]: Schedule webhook for retry. Err(e).change_context(errors::WebhooksFlowError::CallToMerchantFailed)?; } Ok(res) => { if res.status().is_success() { metrics::WEBHOOK_OUTGOING_RECEIVED_COUNT.add( &metrics::CONTEXT, 1, &[metrics::KeyValue::new( MERCHANT_ID, business_profile.merchant_id.clone(), )], ); let update_event = storage::EventUpdate::UpdateWebhookNotified { is_webhook_notified: Some(true), }; state .store .update_event(outgoing_webhook_event_id, update_event) .await .change_context(errors::WebhooksFlowError::WebhookEventUpdationFailed)?; } else { metrics::WEBHOOK_OUTGOING_NOT_RECEIVED_COUNT.add( &metrics::CONTEXT, 1, &[metrics::KeyValue::new( MERCHANT_ID, business_profile.merchant_id.clone(), )], ); // [#217]: Schedule webhook for retry. Err(errors::WebhooksFlowError::NotReceivedByMerchant).into_report()?; } } } Ok(()) } pub async fn webhooks_wrapper( flow: &impl router_env::types::FlowMetric, state: AppState, req: &actix_web::HttpRequest, merchant_account: domain::MerchantAccount, key_store: domain::MerchantKeyStore, connector_name_or_mca_id: &str, body: actix_web::web::Bytes, ) -> RouterResponse { let start_instant = Instant::now(); let (application_response, webhooks_response_tracker, serialized_req) = Box::pin(webhooks_core::( state.clone(), req, merchant_account.clone(), key_store, connector_name_or_mca_id, body.clone(), )) .await?; let request_duration = Instant::now() .saturating_duration_since(start_instant) .as_millis(); let request_id = RequestId::extract(req) .await .into_report() .attach_printable("Unable to extract request id from request") .change_context(errors::ApiErrorResponse::InternalServerError)?; let auth_type = auth::AuthenticationType::WebhookAuth { merchant_id: merchant_account.merchant_id.clone(), }; let status_code = 200; let api_event = ApiEventsType::Webhooks { connector: connector_name_or_mca_id.to_string(), payment_id: webhooks_response_tracker.get_payment_id(), }; let response_value = serde_json::to_value(&webhooks_response_tracker) .into_report() .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Could not convert webhook effect to string")?; let api_event = ApiEvent::new( Some(merchant_account.merchant_id.clone()), flow, &request_id, request_duration, status_code, serialized_req, Some(response_value), None, auth_type, None, api_event, req, req.method(), ); match api_event.clone().try_into() { Ok(event) => { state.event_handler().log_event(event); } Err(err) => { logger::error!(error=?err, event=?api_event, "Error Logging API Event"); } } Ok(application_response) } #[instrument(skip_all)] pub async fn webhooks_core( state: AppState, req: &actix_web::HttpRequest, merchant_account: domain::MerchantAccount, key_store: domain::MerchantKeyStore, connector_name_or_mca_id: &str, body: actix_web::web::Bytes, ) -> errors::RouterResult<( services::ApplicationResponse, WebhookResponseTracker, serde_json::Value, )> { metrics::WEBHOOK_INCOMING_COUNT.add( &metrics::CONTEXT, 1, &[metrics::KeyValue::new( MERCHANT_ID, merchant_account.merchant_id.clone(), )], ); let mut request_details = api::IncomingWebhookRequestDetails { method: req.method().clone(), uri: req.uri().clone(), headers: req.headers(), query_params: req.query_string().to_string(), body: &body, }; // Fetch the merchant connector account to get the webhooks source secret // `webhooks source secret` is a secret shared between the merchant and connector // This is used for source verification and webhooks integrity let (merchant_connector_account, connector) = fetch_mca_and_connector( &state, &merchant_account, connector_name_or_mca_id, &key_store, &request_details, ) .await?; let connector_name = merchant_connector_account.clone().connector_name; let connector = connector.connector; let decoded_body = connector .decode_webhook_body( &*state.clone().store, &request_details, &merchant_account.merchant_id, ) .await .switch() .attach_printable("There was an error in incoming webhook body decoding")?; request_details.body = &decoded_body; let event_type = match connector .get_webhook_event_type(&request_details) .allow_webhook_event_type_not_found( state .clone() .conf .webhooks .ignore_error .event_type .unwrap_or(true), ) .switch() .attach_printable("Could not find event type in incoming webhook body")? { Some(event_type) => event_type, // Early return allows us to acknowledge the webhooks that we do not support None => { logger::error!( webhook_payload =? request_details.body, "Failed while identifying the event type", ); metrics::WEBHOOK_EVENT_TYPE_IDENTIFICATION_FAILURE_COUNT.add( &metrics::CONTEXT, 1, &[ metrics::KeyValue::new(MERCHANT_ID, merchant_account.merchant_id.clone()), metrics::KeyValue::new("connector", connector_name.to_string()), ], ); let response = connector .get_webhook_api_response(&request_details) .switch() .attach_printable("Failed while early return in case of event type parsing")?; return Ok(( response, WebhookResponseTracker::NoEffect, serde_json::Value::Null, )); } }; logger::info!(event_type=?event_type); let is_webhook_event_supported = !matches!( event_type, api_models::webhooks::IncomingWebhookEvent::EventNotSupported ); let is_webhook_event_enabled = !utils::is_webhook_event_disabled( &*state.clone().store, connector_name.as_str(), &merchant_account.merchant_id, &event_type, ) .await; //process webhook further only if webhook event is enabled and is not event_not_supported let process_webhook_further = is_webhook_event_enabled && is_webhook_event_supported; logger::info!(process_webhook=?process_webhook_further); let flow_type: api::WebhookFlow = event_type.to_owned().into(); let mut event_object: Box = Box::new(serde_json::Value::Null); let webhook_effect = if process_webhook_further && !matches!(flow_type, api::WebhookFlow::ReturnResponse) { let object_ref_id = connector .get_webhook_object_reference_id(&request_details) .switch() .attach_printable("Could not find object reference id in incoming webhook body")?; let connector_enum = api_models::enums::Connector::from_str(&connector_name) .into_report() .change_context(errors::ApiErrorResponse::InvalidDataValue { field_name: "connector", }) .attach_printable_lazy(|| { format!("unable to parse connector name {connector_name:?}") })?; let connectors_with_source_verification_call = &state.conf.webhook_source_verification_call; let source_verified = if connectors_with_source_verification_call .connectors_with_webhook_source_verification_call .contains(&connector_enum) { connector .verify_webhook_source_verification_call( &state, &merchant_account, merchant_connector_account.clone(), &connector_name, &request_details, ) .await .or_else(|error| match error.current_context() { errors::ConnectorError::WebhookSourceVerificationFailed => { logger::error!(?error, "Source Verification Failed"); Ok(false) } _ => Err(error), }) .switch() .attach_printable("There was an issue in incoming webhook source verification")? } else { connector .verify_webhook_source( &request_details, &merchant_account, merchant_connector_account.clone(), connector_name.as_str(), ) .await .or_else(|error| match error.current_context() { errors::ConnectorError::WebhookSourceVerificationFailed => { logger::error!(?error, "Source Verification Failed"); Ok(false) } _ => Err(error), }) .switch() .attach_printable("There was an issue in incoming webhook source verification")? }; if source_verified { metrics::WEBHOOK_SOURCE_VERIFIED_COUNT.add( &metrics::CONTEXT, 1, &[metrics::KeyValue::new( MERCHANT_ID, merchant_account.merchant_id.clone(), )], ); } else if connector.is_webhook_source_verification_mandatory() { // if webhook consumption is mandatory for connector, fail webhook // so that merchant can retrigger it after updating merchant_secret return Err(errors::ApiErrorResponse::WebhookAuthenticationFailed.into()); } logger::info!(source_verified=?source_verified); event_object = connector .get_webhook_resource_object(&request_details) .switch() .attach_printable("Could not find resource object in incoming webhook body")?; let webhook_details = api::IncomingWebhookDetails { object_reference_id: object_ref_id, resource_object: serde_json::to_vec(&event_object) .into_report() .change_context(errors::ParsingError::EncodeError("byte-vec")) .attach_printable_lazy(|| { "Unable to convert webhook paylaod to a value".to_string() }) .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable( "There was an issue when encoding the incoming webhook body to bytes", )?, }; let profile_id = merchant_connector_account .profile_id .as_ref() .get_required_value("profile_id") .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Could not find profile_id in merchant connector account")?; let business_profile = state .store .find_business_profile_by_profile_id(profile_id) .await .to_not_found_response(errors::ApiErrorResponse::BusinessProfileNotFound { id: profile_id.to_string(), })?; match flow_type { api::WebhookFlow::Payment => Box::pin(payments_incoming_webhook_flow::( state.clone(), merchant_account, business_profile, key_store, webhook_details, source_verified, )) .await .attach_printable("Incoming webhook flow for payments failed")?, api::WebhookFlow::Refund => Box::pin(refunds_incoming_webhook_flow::( state.clone(), merchant_account, business_profile, key_store, webhook_details, connector_name.as_str(), source_verified, event_type, )) .await .attach_printable("Incoming webhook flow for refunds failed")?, api::WebhookFlow::Dispute => disputes_incoming_webhook_flow::( state.clone(), merchant_account, business_profile, webhook_details, source_verified, *connector, &request_details, event_type, ) .await .attach_printable("Incoming webhook flow for disputes failed")?, api::WebhookFlow::BankTransfer => Box::pin(bank_transfer_webhook_flow::( state.clone(), merchant_account, business_profile, key_store, webhook_details, source_verified, )) .await .attach_printable("Incoming bank-transfer webhook flow failed")?, api::WebhookFlow::ReturnResponse => WebhookResponseTracker::NoEffect, api::WebhookFlow::Mandate => mandates_incoming_webhook_flow::( state.clone(), merchant_account, business_profile, webhook_details, source_verified, event_type, ) .await .attach_printable("Incoming webhook flow for mandates failed")?, _ => Err(errors::ApiErrorResponse::InternalServerError) .into_report() .attach_printable("Unsupported Flow Type received in incoming webhooks")?, } } else { metrics::WEBHOOK_INCOMING_FILTERED_COUNT.add( &metrics::CONTEXT, 1, &[metrics::KeyValue::new( MERCHANT_ID, merchant_account.merchant_id.clone(), )], ); WebhookResponseTracker::NoEffect }; let response = connector .get_webhook_api_response(&request_details) .switch() .attach_printable("Could not get incoming webhook api response from connector")?; let serialized_request = event_object .masked_serialize() .into_report() .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Could not convert webhook effect to string")?; Ok((response, webhook_effect, serialized_request)) } #[inline] pub async fn get_payment_id( db: &dyn StorageInterface, payment_id: &api::PaymentIdType, merchant_id: &str, storage_scheme: enums::MerchantStorageScheme, ) -> errors::RouterResult { let pay_id = || async { match payment_id { api_models::payments::PaymentIdType::PaymentIntentId(ref id) => Ok(id.to_string()), api_models::payments::PaymentIdType::ConnectorTransactionId(ref id) => db .find_payment_attempt_by_merchant_id_connector_txn_id( merchant_id, id, storage_scheme, ) .await .map(|p| p.payment_id), api_models::payments::PaymentIdType::PaymentAttemptId(ref id) => db .find_payment_attempt_by_attempt_id_merchant_id(id, merchant_id, storage_scheme) .await .map(|p| p.payment_id), api_models::payments::PaymentIdType::PreprocessingId(ref id) => db .find_payment_attempt_by_preprocessing_id_merchant_id( id, merchant_id, storage_scheme, ) .await .map(|p| p.payment_id), } }; pay_id() .await .to_not_found_response(errors::ApiErrorResponse::PaymentNotFound) } async fn fetch_mca_and_connector( state: &AppState, merchant_account: &domain::MerchantAccount, connector_name_or_mca_id: &str, key_store: &domain::MerchantKeyStore, request_details: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult<(domain::MerchantConnectorAccount, api::ConnectorData), errors::ApiErrorResponse> { let db = &state.store; if connector_name_or_mca_id.starts_with("mca_") { let mca = db .find_by_merchant_connector_account_merchant_id_merchant_connector_id( &merchant_account.merchant_id, connector_name_or_mca_id, key_store, ) .await .to_not_found_response(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { id: connector_name_or_mca_id.to_string(), }) .attach_printable( "error while fetching merchant_connector_account from connector_id", )?; let connector = api::ConnectorData::get_connector_by_name( &state.conf.connectors, &mca.connector_name, api::GetToken::Connector, Some(mca.merchant_connector_id.clone()), ) .change_context(errors::ApiErrorResponse::InvalidRequestData { message: "invalid connector name received".to_string(), }) .attach_printable("Failed construction of ConnectorData")?; Ok((mca, connector)) } else { // Merchant connector account is already being queried, it is safe to set connector id as None let connector = api::ConnectorData::get_connector_by_name( &state.conf.connectors, connector_name_or_mca_id, api::GetToken::Connector, None, ) .change_context(errors::ApiErrorResponse::InvalidRequestData { message: "invalid connector name received".to_string(), }) .attach_printable("Failed construction of ConnectorData")?; let object_ref_id = connector .connector .get_webhook_object_reference_id(request_details) .switch() .attach_printable("Could not find object reference id in incoming webhook body")?; let profile_id = helper_utils::get_profile_id_using_object_reference_id( &*state.store, object_ref_id, merchant_account, connector_name_or_mca_id, ) .await .change_context(errors::ApiErrorResponse::InvalidDataValue { field_name: "object reference id", }) .attach_printable("Could not find profile id from object reference id")?; let mca = db .find_merchant_connector_account_by_profile_id_connector_name( &profile_id, connector_name_or_mca_id, key_store, ) .await .to_not_found_response(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { id: format!( "profile_id {profile_id} and connector name {connector_name_or_mca_id}" ), }) .attach_printable("error while fetching merchant_connector_account from profile_id")?; Ok((mca, connector)) } }