mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-11-03 05:17:02 +08:00
feat(analytics): FRM Analytics (#4880)
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Co-authored-by: Abhitator216 <abhishek.kanojia@juspay.in> Co-authored-by: Abhishek Kanojia <89402434+Abhitator216@users.noreply.github.com> Co-authored-by: ivor-juspay <138492857+ivor-juspay@users.noreply.github.com> Co-authored-by: Sampras Lopes <sampras.lopes@juspay.in>
This commit is contained in:
@ -14,9 +14,10 @@ pub mod routes {
|
||||
},
|
||||
GenerateReportRequest, GetActivePaymentsMetricRequest, GetApiEventFiltersRequest,
|
||||
GetApiEventMetricRequest, GetAuthEventMetricRequest, GetDisputeMetricRequest,
|
||||
GetPaymentFiltersRequest, GetPaymentIntentFiltersRequest, GetPaymentIntentMetricRequest,
|
||||
GetPaymentMetricRequest, GetRefundFilterRequest, GetRefundMetricRequest,
|
||||
GetSdkEventFiltersRequest, GetSdkEventMetricRequest, ReportRequest,
|
||||
GetFrmFilterRequest, GetFrmMetricRequest, GetPaymentFiltersRequest,
|
||||
GetPaymentIntentFiltersRequest, GetPaymentIntentMetricRequest, GetPaymentMetricRequest,
|
||||
GetRefundFilterRequest, GetRefundMetricRequest, GetSdkEventFiltersRequest,
|
||||
GetSdkEventMetricRequest, ReportRequest,
|
||||
};
|
||||
use error_stack::ResultExt;
|
||||
|
||||
@ -54,6 +55,9 @@ pub mod routes {
|
||||
web::resource("filters/payments")
|
||||
.route(web::post().to(get_payment_filters)),
|
||||
)
|
||||
.service(
|
||||
web::resource("filters/frm").route(web::post().to(get_frm_filters)),
|
||||
)
|
||||
.service(
|
||||
web::resource("filters/refunds")
|
||||
.route(web::post().to(get_refund_filters)),
|
||||
@ -87,6 +91,9 @@ pub mod routes {
|
||||
web::resource("metrics/auth_events")
|
||||
.route(web::post().to(get_auth_event_metrics)),
|
||||
)
|
||||
.service(
|
||||
web::resource("metrics/frm").route(web::post().to(get_frm_metrics)),
|
||||
)
|
||||
.service(
|
||||
web::resource("api_event_logs").route(web::get().to(get_api_events)),
|
||||
)
|
||||
@ -270,6 +277,38 @@ pub mod routes {
|
||||
.await
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `json_payload` array does not contain one `GetFrmMetricRequest` element.
|
||||
pub async fn get_frm_metrics(
|
||||
state: web::Data<AppState>,
|
||||
req: actix_web::HttpRequest,
|
||||
json_payload: web::Json<[GetFrmMetricRequest; 1]>,
|
||||
) -> impl Responder {
|
||||
#[allow(clippy::expect_used)]
|
||||
// safety: This shouldn't panic owing to the data type
|
||||
let payload = json_payload
|
||||
.into_inner()
|
||||
.to_vec()
|
||||
.pop()
|
||||
.expect("Couldn't get GetFrmMetricRequest");
|
||||
let flow = AnalyticsFlow::GetFrmMetrics;
|
||||
Box::pin(api::server_wrap(
|
||||
flow,
|
||||
state,
|
||||
&req,
|
||||
payload,
|
||||
|state, auth: AuthenticationData, req, _| async move {
|
||||
analytics::frm::get_metrics(&state.pool, &auth.merchant_account.merchant_id, req)
|
||||
.await
|
||||
.map(ApplicationResponse::Json)
|
||||
},
|
||||
&auth::JWTAuth(Permission::Analytics),
|
||||
api_locking::LockAction::NotApplicable,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `json_payload` array does not contain one `GetSdkEventMetricRequest` element.
|
||||
@ -458,6 +497,28 @@ pub mod routes {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_frm_filters(
|
||||
state: web::Data<AppState>,
|
||||
req: actix_web::HttpRequest,
|
||||
json_payload: web::Json<GetFrmFilterRequest>,
|
||||
) -> impl Responder {
|
||||
let flow = AnalyticsFlow::GetFrmFilters;
|
||||
Box::pin(api::server_wrap(
|
||||
flow,
|
||||
state,
|
||||
&req,
|
||||
json_payload.into_inner(),
|
||||
|state, auth: AuthenticationData, req: GetFrmFilterRequest, _| async move {
|
||||
analytics::frm::get_filters(&state.pool, req, &auth.merchant_account.merchant_id)
|
||||
.await
|
||||
.map(ApplicationResponse::Json)
|
||||
},
|
||||
&auth::JWTAuth(Permission::Analytics),
|
||||
api_locking::LockAction::NotApplicable,
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_sdk_event_filters(
|
||||
state: web::Data<AppState>,
|
||||
req: actix_web::HttpRequest,
|
||||
|
||||
@ -36,9 +36,8 @@ pub mod user;
|
||||
pub mod user_authentication_method;
|
||||
pub mod user_key_store;
|
||||
pub mod user_role;
|
||||
|
||||
use diesel_models::{
|
||||
fraud_check::{FraudCheck, FraudCheckNew, FraudCheckUpdate},
|
||||
fraud_check::{FraudCheck, FraudCheckUpdate},
|
||||
organization::{Organization, OrganizationNew, OrganizationUpdate},
|
||||
};
|
||||
use error_stack::ResultExt;
|
||||
@ -53,16 +52,23 @@ use hyperswitch_domain_models::payouts::{
|
||||
use hyperswitch_domain_models::{PayoutAttemptInterface, PayoutsInterface};
|
||||
use masking::PeekInterface;
|
||||
use redis_interface::errors::RedisError;
|
||||
use router_env::logger;
|
||||
use storage_impl::{errors::StorageError, redis::kv_store::RedisConnInterface, MockDb};
|
||||
|
||||
pub use self::kafka_store::KafkaStore;
|
||||
use self::{fraud_check::FraudCheckInterface, organization::OrganizationInterface};
|
||||
pub use crate::{
|
||||
core::errors::{self, ProcessTrackerError},
|
||||
errors::CustomResult,
|
||||
services::{
|
||||
kafka::{KafkaError, KafkaProducer, MQResult},
|
||||
Store,
|
||||
},
|
||||
types::{
|
||||
domain,
|
||||
storage::{self},
|
||||
AccessToken,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
@ -259,36 +265,74 @@ impl RequestIdStore for KafkaStore {
|
||||
impl FraudCheckInterface for KafkaStore {
|
||||
async fn insert_fraud_check_response(
|
||||
&self,
|
||||
new: FraudCheckNew,
|
||||
new: storage::FraudCheckNew,
|
||||
) -> CustomResult<FraudCheck, StorageError> {
|
||||
self.diesel_store.insert_fraud_check_response(new).await
|
||||
let frm = self.diesel_store.insert_fraud_check_response(new).await?;
|
||||
if let Err(er) = self
|
||||
.kafka_producer
|
||||
.log_fraud_check(&frm, None, self.tenant_id.clone())
|
||||
.await
|
||||
{
|
||||
logger::error!(message = "Failed to log analytics event for fraud check", error_message = ?er);
|
||||
}
|
||||
Ok(frm)
|
||||
}
|
||||
async fn update_fraud_check_response_with_attempt_id(
|
||||
&self,
|
||||
fraud_check: FraudCheck,
|
||||
fraud_check_update: FraudCheckUpdate,
|
||||
this: FraudCheck,
|
||||
fraud_check: FraudCheckUpdate,
|
||||
) -> CustomResult<FraudCheck, StorageError> {
|
||||
self.diesel_store
|
||||
.update_fraud_check_response_with_attempt_id(fraud_check, fraud_check_update)
|
||||
let frm = self
|
||||
.diesel_store
|
||||
.update_fraud_check_response_with_attempt_id(this, fraud_check)
|
||||
.await?;
|
||||
if let Err(er) = self
|
||||
.kafka_producer
|
||||
.log_fraud_check(&frm, None, self.tenant_id.clone())
|
||||
.await
|
||||
{
|
||||
logger::error!(message="Failed to log analytics event for fraud check {frm:?}", error_message=?er)
|
||||
}
|
||||
Ok(frm)
|
||||
}
|
||||
async fn find_fraud_check_by_payment_id(
|
||||
&self,
|
||||
payment_id: String,
|
||||
merchant_id: String,
|
||||
) -> CustomResult<FraudCheck, StorageError> {
|
||||
self.diesel_store
|
||||
let frm = self
|
||||
.diesel_store
|
||||
.find_fraud_check_by_payment_id(payment_id, merchant_id)
|
||||
.await?;
|
||||
if let Err(er) = self
|
||||
.kafka_producer
|
||||
.log_fraud_check(&frm, None, self.tenant_id.clone())
|
||||
.await
|
||||
{
|
||||
logger::error!(message="Failed to log analytics event for fraud check {frm:?}", error_message=?er)
|
||||
}
|
||||
Ok(frm)
|
||||
}
|
||||
async fn find_fraud_check_by_payment_id_if_present(
|
||||
&self,
|
||||
payment_id: String,
|
||||
merchant_id: String,
|
||||
) -> CustomResult<Option<FraudCheck>, StorageError> {
|
||||
self.diesel_store
|
||||
let frm = self
|
||||
.diesel_store
|
||||
.find_fraud_check_by_payment_id_if_present(payment_id, merchant_id)
|
||||
.await
|
||||
.await?;
|
||||
|
||||
if let Some(fraud_check) = frm.clone() {
|
||||
if let Err(er) = self
|
||||
.kafka_producer
|
||||
.log_fraud_check(&fraud_check, None, self.tenant_id.clone())
|
||||
.await
|
||||
{
|
||||
logger::error!(message="Failed to log analytics event for frm {frm:?}", error_message=?er);
|
||||
}
|
||||
}
|
||||
Ok(frm)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -116,33 +116,3 @@ impl FraudCheckInterface for MockDb {
|
||||
Err(errors::StorageError::MockDbError)?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "kafka_events")]
|
||||
#[async_trait::async_trait]
|
||||
impl FraudCheckInterface for super::KafkaStore {
|
||||
#[instrument(skip_all)]
|
||||
async fn insert_fraud_check_response(
|
||||
&self,
|
||||
_new: storage::FraudCheckNew,
|
||||
) -> CustomResult<FraudCheck, errors::StorageError> {
|
||||
Err(errors::StorageError::MockDbError)?
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn update_fraud_check_response_with_attempt_id(
|
||||
&self,
|
||||
_this: FraudCheck,
|
||||
_fraud_check: FraudCheckUpdate,
|
||||
) -> CustomResult<FraudCheck, errors::StorageError> {
|
||||
Err(errors::StorageError::MockDbError)?
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn find_fraud_check_by_payment_id(
|
||||
&self,
|
||||
_payment_id: String,
|
||||
_merchant_id: String,
|
||||
) -> CustomResult<FraudCheck, errors::StorageError> {
|
||||
Err(errors::StorageError::MockDbError)?
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ pub struct TenantID(pub String);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct KafkaStore {
|
||||
kafka_producer: KafkaProducer,
|
||||
pub kafka_producer: KafkaProducer,
|
||||
pub diesel_store: Store,
|
||||
pub tenant_id: TenantID,
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ pub mod outgoing_webhook_logs;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventType {
|
||||
PaymentIntent,
|
||||
FraudCheck,
|
||||
PaymentAttempt,
|
||||
Refund,
|
||||
ApiLogs,
|
||||
|
||||
@ -11,11 +11,15 @@ use rdkafka::{
|
||||
};
|
||||
#[cfg(feature = "payouts")]
|
||||
pub mod payout;
|
||||
use crate::events::EventType;
|
||||
use diesel_models::fraud_check::FraudCheck;
|
||||
|
||||
use crate::{events::EventType, services::kafka::fraud_check_event::KafkaFraudCheckEvent};
|
||||
mod authentication;
|
||||
mod authentication_event;
|
||||
mod dispute;
|
||||
mod dispute_event;
|
||||
mod fraud_check;
|
||||
mod fraud_check_event;
|
||||
mod payment_attempt;
|
||||
mod payment_attempt_event;
|
||||
mod payment_intent;
|
||||
@ -36,7 +40,7 @@ use self::{
|
||||
payment_intent_event::KafkaPaymentIntentEvent, refund::KafkaRefund,
|
||||
refund_event::KafkaRefundEvent,
|
||||
};
|
||||
use crate::types::storage::Dispute;
|
||||
use crate::{services::kafka::fraud_check::KafkaFraudCheck, types::storage::Dispute};
|
||||
|
||||
// Using message queue result here to avoid confusion with Kafka result provided by library
|
||||
pub type MQResult<T> = CustomResult<T, KafkaError>;
|
||||
@ -139,6 +143,7 @@ impl<'a, T: KafkaMessage> KafkaMessage for KafkaConsolidatedEvent<'a, T> {
|
||||
#[serde(default)]
|
||||
pub struct KafkaSettings {
|
||||
brokers: Vec<String>,
|
||||
fraud_check_analytics_topic: String,
|
||||
intent_analytics_topic: String,
|
||||
attempt_analytics_topic: String,
|
||||
refund_analytics_topic: String,
|
||||
@ -246,6 +251,7 @@ impl KafkaSettings {
|
||||
pub struct KafkaProducer {
|
||||
producer: Arc<RdKafkaProducer>,
|
||||
intent_analytics_topic: String,
|
||||
fraud_check_analytics_topic: String,
|
||||
attempt_analytics_topic: String,
|
||||
refund_analytics_topic: String,
|
||||
api_logs_topic: String,
|
||||
@ -288,6 +294,7 @@ impl KafkaProducer {
|
||||
.change_context(KafkaError::InitializationError)?,
|
||||
)),
|
||||
|
||||
fraud_check_analytics_topic: conf.fraud_check_analytics_topic.clone(),
|
||||
intent_analytics_topic: conf.intent_analytics_topic.clone(),
|
||||
attempt_analytics_topic: conf.attempt_analytics_topic.clone(),
|
||||
refund_analytics_topic: conf.refund_analytics_topic.clone(),
|
||||
@ -321,6 +328,38 @@ impl KafkaProducer {
|
||||
.map_err(|(error, record)| report!(error).attach_printable(format!("{record:?}")))
|
||||
.change_context(KafkaError::GenericError)
|
||||
}
|
||||
pub async fn log_fraud_check(
|
||||
&self,
|
||||
attempt: &FraudCheck,
|
||||
old_attempt: Option<FraudCheck>,
|
||||
tenant_id: TenantID,
|
||||
) -> MQResult<()> {
|
||||
if let Some(negative_event) = old_attempt {
|
||||
self.log_event(&KafkaEvent::old(
|
||||
&KafkaFraudCheck::from_storage(&negative_event),
|
||||
tenant_id.clone(),
|
||||
))
|
||||
.attach_printable_lazy(|| {
|
||||
format!("Failed to add negative fraud check event {negative_event:?}")
|
||||
})?;
|
||||
};
|
||||
|
||||
self.log_event(&KafkaEvent::new(
|
||||
&KafkaFraudCheck::from_storage(attempt),
|
||||
tenant_id.clone(),
|
||||
))
|
||||
.attach_printable_lazy(|| {
|
||||
format!("Failed to add positive fraud check event {attempt:?}")
|
||||
})?;
|
||||
|
||||
self.log_event(&KafkaConsolidatedEvent::new(
|
||||
&KafkaFraudCheckEvent::from_storage(attempt),
|
||||
tenant_id.clone(),
|
||||
))
|
||||
.attach_printable_lazy(|| {
|
||||
format!("Failed to add consolidated fraud check event {attempt:?}")
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn log_payment_attempt(
|
||||
&self,
|
||||
@ -544,6 +583,7 @@ impl KafkaProducer {
|
||||
|
||||
pub fn get_topic(&self, event: EventType) -> &str {
|
||||
match event {
|
||||
EventType::FraudCheck => &self.fraud_check_analytics_topic,
|
||||
EventType::ApiLogs => &self.api_logs_topic,
|
||||
EventType::PaymentAttempt => &self.attempt_analytics_topic,
|
||||
EventType::PaymentIntent => &self.intent_analytics_topic,
|
||||
|
||||
67
crates/router/src/services/kafka/fraud_check.rs
Normal file
67
crates/router/src/services/kafka/fraud_check.rs
Normal file
@ -0,0 +1,67 @@
|
||||
// use diesel_models::enums as storage_enums;
|
||||
use diesel_models::{
|
||||
enums as storage_enums,
|
||||
enums::{FraudCheckLastStep, FraudCheckStatus, FraudCheckType},
|
||||
fraud_check::FraudCheck,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct KafkaFraudCheck<'a> {
|
||||
pub frm_id: &'a String,
|
||||
pub payment_id: &'a String,
|
||||
pub merchant_id: &'a String,
|
||||
pub attempt_id: &'a String,
|
||||
#[serde(with = "time::serde::timestamp")]
|
||||
pub created_at: OffsetDateTime,
|
||||
pub frm_name: &'a String,
|
||||
pub frm_transaction_id: Option<&'a String>,
|
||||
pub frm_transaction_type: FraudCheckType,
|
||||
pub frm_status: FraudCheckStatus,
|
||||
pub frm_score: Option<i32>,
|
||||
pub frm_reason: Option<serde_json::Value>,
|
||||
pub frm_error: Option<&'a String>,
|
||||
pub payment_details: Option<serde_json::Value>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(with = "time::serde::timestamp")]
|
||||
pub modified_at: OffsetDateTime,
|
||||
pub last_step: FraudCheckLastStep,
|
||||
pub payment_capture_method: Option<storage_enums::CaptureMethod>, // In postFrm, we are updating capture method from automatic to manual. To store the merchant actual capture method, we are storing the actual capture method in payment_capture_method. It will be useful while approving the FRM decision.
|
||||
}
|
||||
|
||||
impl<'a> KafkaFraudCheck<'a> {
|
||||
pub fn from_storage(check: &'a FraudCheck) -> Self {
|
||||
Self {
|
||||
frm_id: &check.frm_id,
|
||||
payment_id: &check.payment_id,
|
||||
merchant_id: &check.merchant_id,
|
||||
attempt_id: &check.attempt_id,
|
||||
created_at: check.created_at.assume_utc(),
|
||||
frm_name: &check.frm_name,
|
||||
frm_transaction_id: check.frm_transaction_id.as_ref(),
|
||||
frm_transaction_type: check.frm_transaction_type,
|
||||
frm_status: check.frm_status,
|
||||
frm_score: check.frm_score,
|
||||
frm_reason: check.frm_reason.clone(),
|
||||
frm_error: check.frm_error.as_ref(),
|
||||
payment_details: check.payment_details.clone(),
|
||||
metadata: check.metadata.clone(),
|
||||
modified_at: check.modified_at.assume_utc(),
|
||||
last_step: check.last_step,
|
||||
payment_capture_method: check.payment_capture_method,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> super::KafkaMessage for KafkaFraudCheck<'a> {
|
||||
fn key(&self) -> String {
|
||||
format!(
|
||||
"{}_{}_{}_{}",
|
||||
self.merchant_id, self.payment_id, self.attempt_id, self.frm_id
|
||||
)
|
||||
}
|
||||
|
||||
fn event_type(&self) -> crate::events::EventType {
|
||||
crate::events::EventType::FraudCheck
|
||||
}
|
||||
}
|
||||
66
crates/router/src/services/kafka/fraud_check_event.rs
Normal file
66
crates/router/src/services/kafka/fraud_check_event.rs
Normal file
@ -0,0 +1,66 @@
|
||||
use diesel_models::{
|
||||
enums as storage_enums,
|
||||
enums::{FraudCheckLastStep, FraudCheckStatus, FraudCheckType},
|
||||
fraud_check::FraudCheck,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub struct KafkaFraudCheckEvent<'a> {
|
||||
pub frm_id: &'a String,
|
||||
pub payment_id: &'a String,
|
||||
pub merchant_id: &'a String,
|
||||
pub attempt_id: &'a String,
|
||||
#[serde(default, with = "time::serde::timestamp::milliseconds")]
|
||||
pub created_at: OffsetDateTime,
|
||||
pub frm_name: &'a String,
|
||||
pub frm_transaction_id: Option<&'a String>,
|
||||
pub frm_transaction_type: FraudCheckType,
|
||||
pub frm_status: FraudCheckStatus,
|
||||
pub frm_score: Option<i32>,
|
||||
pub frm_reason: Option<serde_json::Value>,
|
||||
pub frm_error: Option<&'a String>,
|
||||
pub payment_details: Option<serde_json::Value>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(default, with = "time::serde::timestamp::milliseconds")]
|
||||
pub modified_at: OffsetDateTime,
|
||||
pub last_step: FraudCheckLastStep,
|
||||
pub payment_capture_method: Option<storage_enums::CaptureMethod>, // In postFrm, we are updating capture method from automatic to manual. To store the merchant actual capture method, we are storing the actual capture method in payment_capture_method. It will be useful while approving the FRM decision.
|
||||
}
|
||||
|
||||
impl<'a> KafkaFraudCheckEvent<'a> {
|
||||
pub fn from_storage(check: &'a FraudCheck) -> Self {
|
||||
Self {
|
||||
frm_id: &check.frm_id,
|
||||
payment_id: &check.payment_id,
|
||||
merchant_id: &check.merchant_id,
|
||||
attempt_id: &check.attempt_id,
|
||||
created_at: check.created_at.assume_utc(),
|
||||
frm_name: &check.frm_name,
|
||||
frm_transaction_id: check.frm_transaction_id.as_ref(),
|
||||
frm_transaction_type: check.frm_transaction_type,
|
||||
frm_status: check.frm_status,
|
||||
frm_score: check.frm_score,
|
||||
frm_reason: check.frm_reason.clone(),
|
||||
frm_error: check.frm_error.as_ref(),
|
||||
payment_details: check.payment_details.clone(),
|
||||
metadata: check.metadata.clone(),
|
||||
modified_at: check.modified_at.assume_utc(),
|
||||
last_step: check.last_step,
|
||||
payment_capture_method: check.payment_capture_method,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> super::KafkaMessage for KafkaFraudCheckEvent<'a> {
|
||||
fn key(&self) -> String {
|
||||
format!(
|
||||
"{}_{}_{}_{}",
|
||||
self.merchant_id, self.payment_id, self.attempt_id, self.frm_id
|
||||
)
|
||||
}
|
||||
|
||||
fn event_type(&self) -> crate::events::EventType {
|
||||
crate::events::EventType::FraudCheck
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user