feat(recovery-events): add revenue recovery topic and vector config to push these events to s3 (#8285)

Co-authored-by: Nishanth Challa <nishanth.challa@Nishanth-Challa-C0WGKCFHLF.local>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: chikke srujan <121822803+srujanchikke@users.noreply.github.com>
This commit is contained in:
CHALLA NISHANTH BABU
2025-07-25 17:16:50 +05:30
committed by GitHub
parent b5dddbc1a8
commit 17d34a29e4
22 changed files with 612 additions and 37 deletions

View File

@ -2146,8 +2146,9 @@ where
let payment_attempt = self.payment_attempt;
let payment_intent = self.payment_intent;
let response = api_models::payments::PaymentAttemptRecordResponse {
id: payment_attempt.get_id().to_owned(),
id: payment_attempt.id.clone(),
status: payment_attempt.status,
amount: payment_attempt.amount_details.get_net_amount(),
payment_intent_feature_metadata: payment_intent
.feature_metadata
.as_ref()
@ -2156,6 +2157,10 @@ where
.feature_metadata
.as_ref()
.map(api_models::payments::PaymentAttemptFeatureMetadata::foreign_from),
error_details: payment_attempt
.error
.map(api_models::payments::RecordAttemptErrorDetails::from),
created_at: payment_attempt.created_at,
};
Ok(services::ApplicationResponse::JsonWithHeaders((
response,
@ -5217,6 +5222,8 @@ impl ForeignFrom<&diesel_models::types::FeatureMetadata> for api_models::payment
first_payment_attempt_pg_error_code: payment_revenue_recovery_metadata
.first_payment_attempt_pg_error_code
.clone(),
invoice_billing_started_at_time: payment_revenue_recovery_metadata
.invoice_billing_started_at_time,
}
});
let apple_pay_details = feature_metadata

View File

@ -34,6 +34,7 @@ use crate::{
errors::{self, RouterResult},
payments::{self, helpers, operations::Operation},
revenue_recovery::{self as revenue_recovery_core},
webhooks::recovery_incoming as recovery_incoming_flow,
},
db::StorageInterface,
logger,
@ -101,6 +102,23 @@ impl RevenueRecoveryPaymentsAttemptStatus {
) -> Result<(), errors::ProcessTrackerError> {
let db = &*state.store;
let recovery_payment_intent =
hyperswitch_domain_models::revenue_recovery::RecoveryPaymentIntent::from(
payment_intent,
);
let recovery_payment_attempt =
hyperswitch_domain_models::revenue_recovery::RecoveryPaymentAttempt::from(
&payment_attempt,
);
let recovery_payment_tuple = recovery_incoming_flow::RecoveryPaymentTuple::new(
&recovery_payment_intent,
&recovery_payment_attempt,
);
let retry_count = process_tracker.retry_count;
match self {
Self::Succeeded => {
// finish psync task as the payment was a success
@ -110,6 +128,19 @@ impl RevenueRecoveryPaymentsAttemptStatus {
business_status::PSYNC_WORKFLOW_COMPLETE,
)
.await?;
// publish events to kafka
if let Err(e) = recovery_incoming_flow::RecoveryPaymentTuple::publish_revenue_recovery_event_to_kafka(
state,
&recovery_payment_tuple,
Some(retry_count+1)
)
.await{
router_env::logger::error!(
"Failed to publish revenue recovery event to kafka: {:?}",
e
);
};
// Record a successful transaction back to Billing Connector
// TODO: Add support for retrying failed outgoing recordback webhooks
record_back_to_billing_connector(
@ -130,6 +161,17 @@ impl RevenueRecoveryPaymentsAttemptStatus {
business_status::PSYNC_WORKFLOW_COMPLETE,
)
.await?;
// publish events to kafka
if let Err(e) = recovery_incoming_flow::RecoveryPaymentTuple::publish_revenue_recovery_event_to_kafka(
state,
&recovery_payment_tuple,
Some(retry_count+1)
)
.await{
router_env::logger::error!(
"Failed to publish revenue recovery event to kafka : {:?}", e
);
};
// get a reschedule time
let schedule_time = get_schedule_time_to_retry_mit_payments(
@ -291,13 +333,66 @@ impl Action {
revenue_recovery_metadata,
)
.await;
let recovery_payment_intent =
hyperswitch_domain_models::revenue_recovery::RecoveryPaymentIntent::from(
payment_intent,
);
// handle proxy api's response
match response {
Ok(payment_data) => match payment_data.payment_attempt.status.foreign_into() {
RevenueRecoveryPaymentsAttemptStatus::Succeeded => Ok(Self::SuccessfulPayment(
payment_data.payment_attempt.clone(),
)),
RevenueRecoveryPaymentsAttemptStatus::Succeeded => {
let recovery_payment_attempt =
hyperswitch_domain_models::revenue_recovery::RecoveryPaymentAttempt::from(
&payment_data.payment_attempt,
);
let recovery_payment_tuple = recovery_incoming_flow::RecoveryPaymentTuple::new(
&recovery_payment_intent,
&recovery_payment_attempt,
);
// publish events to kafka
if let Err(e) = recovery_incoming_flow::RecoveryPaymentTuple::publish_revenue_recovery_event_to_kafka(
state,
&recovery_payment_tuple,
Some(process.retry_count+1)
)
.await{
router_env::logger::error!(
"Failed to publish revenue recovery event to kafka: {:?}",
e
);
};
Ok(Self::SuccessfulPayment(
payment_data.payment_attempt.clone(),
))
}
RevenueRecoveryPaymentsAttemptStatus::Failed => {
let recovery_payment_attempt =
hyperswitch_domain_models::revenue_recovery::RecoveryPaymentAttempt::from(
&payment_data.payment_attempt,
);
let recovery_payment_tuple = recovery_incoming_flow::RecoveryPaymentTuple::new(
&recovery_payment_intent,
&recovery_payment_attempt,
);
// publish events to kafka
if let Err(e) = recovery_incoming_flow::RecoveryPaymentTuple::publish_revenue_recovery_event_to_kafka(
state,
&recovery_payment_tuple,
Some(process.retry_count+1)
)
.await{
router_env::logger::error!(
"Failed to publish revenue recovery event to kafka: {:?}",
e
);
};
Self::decide_retry_failure_action(
db,
merchant_id,

View File

@ -13,7 +13,9 @@ use hyperswitch_domain_models::{
router_response_types::revenue_recovery as revenue_recovery_response, types as router_types,
};
use hyperswitch_interfaces::webhooks as interface_webhooks;
use masking::{PeekInterface, Secret};
use router_env::{instrument, tracing};
use services::kafka;
use crate::{
core::{
@ -133,6 +135,25 @@ pub async fn recovery_incoming_webhook_flow(
)
.await?;
// Publish event to Kafka
if let Some(ref attempt) = recovery_attempt_from_payment_attempt {
// Passing `merchant_context` here
let recovery_payment_tuple =
&RecoveryPaymentTuple::new(&recovery_intent_from_payment_attempt, attempt);
if let Err(e) = RecoveryPaymentTuple::publish_revenue_recovery_event_to_kafka(
&state,
recovery_payment_tuple,
None,
)
.await
{
router_env::logger::error!(
"Failed to publish revenue recovery event to kafka : {:?}",
e
);
};
}
let attempt_triggered_by = recovery_attempt_from_payment_attempt
.as_ref()
.and_then(|attempt| attempt.get_attempt_triggered_by());
@ -342,10 +363,20 @@ impl RevenueRecoveryInvoice {
let payment_id = payments_response.id.clone();
let status = payments_response.status;
let feature_metadata = payments_response.feature_metadata;
let merchant_id = merchant_context.get_merchant_account().get_id().clone();
let revenue_recovery_invoice_data = &self.0;
Ok(Some(revenue_recovery::RecoveryPaymentIntent {
payment_id,
status,
feature_metadata,
merchant_id,
merchant_reference_id: Some(
revenue_recovery_invoice_data.merchant_reference_id.clone(),
),
invoice_amount: revenue_recovery_invoice_data.amount,
invoice_currency: revenue_recovery_invoice_data.currency,
created_at: revenue_recovery_invoice_data.billing_started_at,
billing_address: revenue_recovery_invoice_data.billing_address.clone(),
}))
}
Err(err)
@ -402,10 +433,21 @@ impl RevenueRecoveryInvoice {
.change_context(errors::RevenueRecoveryError::PaymentIntentCreateFailed)
.attach_printable("expected json response")?;
let merchant_id = merchant_context.get_merchant_account().get_id().clone();
let revenue_recovery_invoice_data = &self.0;
Ok(revenue_recovery::RecoveryPaymentIntent {
payment_id: response.id,
status: response.status,
feature_metadata: response.feature_metadata,
merchant_id,
merchant_reference_id: Some(
revenue_recovery_invoice_data.merchant_reference_id.clone(),
),
invoice_amount: revenue_recovery_invoice_data.amount,
invoice_currency: revenue_recovery_invoice_data.currency,
created_at: revenue_recovery_invoice_data.billing_started_at,
billing_address: revenue_recovery_invoice_data.billing_address.clone(),
})
}
}
@ -511,10 +553,18 @@ impl RevenueRecoveryAttempt {
})
});
let payment_attempt =
final_attempt.map(|attempt_res| revenue_recovery::RecoveryPaymentAttempt {
attempt_id: attempt_res.id.to_owned(),
attempt_status: attempt_res.status.to_owned(),
feature_metadata: attempt_res.feature_metadata.to_owned(),
final_attempt.map(|res| revenue_recovery::RecoveryPaymentAttempt {
attempt_id: res.id.to_owned(),
attempt_status: res.status.to_owned(),
feature_metadata: res.feature_metadata.to_owned(),
amount: res.amount.net_amount,
network_advice_code: res.error.clone().and_then(|e| e.network_advice_code), // Placeholder, to be populated if available
network_decline_code: res
.error
.clone()
.and_then(|e| e.network_decline_code), // Placeholder, to be populated if available
error_code: res.error.clone().map(|error| error.code),
created_at: res.created_at,
});
// If we have an attempt, combine it with payment_intent in a tuple.
let res_with_payment_intent_and_attempt =
@ -579,11 +629,31 @@ impl RevenueRecoveryAttempt {
attempt_id: attempt_response.id.clone(),
attempt_status: attempt_response.status,
feature_metadata: attempt_response.payment_attempt_feature_metadata,
amount: attempt_response.amount,
network_advice_code: attempt_response
.error_details
.clone()
.and_then(|error| error.network_decline_code), // Placeholder, to be populated if available
network_decline_code: attempt_response
.error_details
.clone()
.and_then(|error| error.network_decline_code), // Placeholder, to be populated if available
error_code: attempt_response
.error_details
.clone()
.map(|error| error.code),
created_at: attempt_response.created_at,
},
revenue_recovery::RecoveryPaymentIntent {
payment_id: payment_intent.payment_id.clone(),
status: attempt_response.status.into(), // Using status from attempt_response
feature_metadata: attempt_response.payment_intent_feature_metadata, // Using feature_metadata from attempt_response
merchant_id: payment_intent.merchant_id.clone(),
merchant_reference_id: payment_intent.merchant_reference_id.clone(),
invoice_amount: payment_intent.invoice_amount,
invoice_currency: payment_intent.invoice_currency,
created_at: payment_intent.created_at,
billing_address: payment_intent.billing_address.clone(),
},
))
}
@ -665,6 +735,8 @@ impl RevenueRecoveryAttempt {
connector_customer_id: revenue_recovery_attempt_data.connector_customer_id.clone(),
retry_count: revenue_recovery_attempt_data.retry_count,
invoice_next_billing_time: revenue_recovery_attempt_data.invoice_next_billing_time,
invoice_billing_started_at_time: revenue_recovery_attempt_data
.invoice_billing_started_at_time,
triggered_by,
card_network: revenue_recovery_attempt_data.card_network.clone(),
card_issuer,
@ -1180,3 +1252,104 @@ impl BillingConnectorInvoiceSyncFlowRouterData {
self.0
}
}
#[derive(Clone, Debug)]
pub struct RecoveryPaymentTuple(
revenue_recovery::RecoveryPaymentIntent,
revenue_recovery::RecoveryPaymentAttempt,
);
impl RecoveryPaymentTuple {
pub fn new(
payment_intent: &revenue_recovery::RecoveryPaymentIntent,
payment_attempt: &revenue_recovery::RecoveryPaymentAttempt,
) -> Self {
Self(payment_intent.clone(), payment_attempt.clone())
}
pub async fn publish_revenue_recovery_event_to_kafka(
state: &SessionState,
recovery_payment_tuple: &Self,
retry_count: Option<i32>,
) -> CustomResult<(), errors::RevenueRecoveryError> {
let recovery_payment_intent = &recovery_payment_tuple.0;
let recovery_payment_attempt = &recovery_payment_tuple.1;
let revenue_recovery_feature_metadata = recovery_payment_intent
.feature_metadata
.as_ref()
.and_then(|metadata| metadata.revenue_recovery.as_ref());
let billing_city = recovery_payment_intent
.billing_address
.as_ref()
.and_then(|billing_address| billing_address.address.as_ref())
.and_then(|address| address.city.clone())
.map(Secret::new);
let billing_state = recovery_payment_intent
.billing_address
.as_ref()
.and_then(|billing_address| billing_address.address.as_ref())
.and_then(|address| address.state.clone());
let billing_country = recovery_payment_intent
.billing_address
.as_ref()
.and_then(|billing_address| billing_address.address.as_ref())
.and_then(|address| address.country);
let card_info = revenue_recovery_feature_metadata.and_then(|metadata| {
metadata
.billing_connector_payment_method_details
.as_ref()
.and_then(|details| details.get_billing_connector_card_info())
});
#[allow(clippy::as_conversions)]
let retry_count = Some(retry_count.unwrap_or_else(|| {
revenue_recovery_feature_metadata
.map(|data| data.total_retry_count as i32)
.unwrap_or(0)
}));
let event = kafka::revenue_recovery::RevenueRecovery {
merchant_id: &recovery_payment_intent.merchant_id,
invoice_amount: recovery_payment_intent.invoice_amount,
invoice_currency: &recovery_payment_intent.invoice_currency,
invoice_date: revenue_recovery_feature_metadata.and_then(|data| {
data.invoice_billing_started_at_time
.map(|time| time.assume_utc())
}),
invoice_due_date: revenue_recovery_feature_metadata
.and_then(|data| data.invoice_next_billing_time.map(|time| time.assume_utc())),
billing_city,
billing_country: billing_country.as_ref(),
billing_state,
attempt_amount: recovery_payment_attempt.amount,
attempt_currency: &recovery_payment_intent.invoice_currency.clone(),
attempt_status: &recovery_payment_attempt.attempt_status.clone(),
pg_error_code: recovery_payment_attempt.error_code.clone(),
network_advice_code: recovery_payment_attempt.network_advice_code.clone(),
network_error_code: recovery_payment_attempt.network_decline_code.clone(),
first_pg_error_code: revenue_recovery_feature_metadata
.and_then(|data| data.first_payment_attempt_pg_error_code.clone()),
first_network_advice_code: revenue_recovery_feature_metadata
.and_then(|data| data.first_payment_attempt_network_advice_code.clone()),
first_network_error_code: revenue_recovery_feature_metadata
.and_then(|data| data.first_payment_attempt_network_decline_code.clone()),
attempt_created_at: recovery_payment_attempt.created_at.assume_utc(),
payment_method_type: revenue_recovery_feature_metadata
.map(|data| &data.payment_method_type),
payment_method_subtype: revenue_recovery_feature_metadata
.map(|data| &data.payment_method_subtype),
card_network: card_info
.as_ref()
.and_then(|info| info.card_network.as_ref()),
card_issuer: card_info.and_then(|data| data.card_issuer.clone()),
retry_count,
payment_gateway: revenue_recovery_feature_metadata.map(|data| data.connector),
};
state.event_handler.log_event(&event);
Ok(())
}
}

View File

@ -39,6 +39,7 @@ pub enum EventType {
Consolidated,
Authentication,
RoutingApiLogs,
RevenueRecovery,
}
#[derive(Debug, Default, Deserialize, Clone)]

View File

@ -28,6 +28,7 @@ mod payment_intent;
mod payment_intent_event;
mod refund;
mod refund_event;
pub mod revenue_recovery;
use diesel_models::{authentication::Authentication, refund::Refund};
use hyperswitch_domain_models::payments::{payment_attempt::PaymentAttempt, PaymentIntent};
use serde::Serialize;
@ -162,6 +163,7 @@ pub struct KafkaSettings {
consolidated_events_topic: String,
authentication_analytics_topic: String,
routing_logs_topic: String,
revenue_recovery_topic: String,
}
impl KafkaSettings {
@ -277,6 +279,7 @@ pub struct KafkaProducer {
authentication_analytics_topic: String,
ckh_database_name: Option<String>,
routing_logs_topic: String,
revenue_recovery_topic: String,
}
struct RdKafkaProducer(ThreadedProducer<DefaultProducerContext>);
@ -327,6 +330,7 @@ impl KafkaProducer {
authentication_analytics_topic: conf.authentication_analytics_topic.clone(),
ckh_database_name: None,
routing_logs_topic: conf.routing_logs_topic.clone(),
revenue_recovery_topic: conf.revenue_recovery_topic.clone(),
})
}
@ -665,6 +669,7 @@ impl KafkaProducer {
EventType::Consolidated => &self.consolidated_events_topic,
EventType::Authentication => &self.authentication_analytics_topic,
EventType::RoutingApiLogs => &self.routing_logs_topic,
EventType::RevenueRecovery => &self.revenue_recovery_topic,
}
}
}

View File

@ -0,0 +1,43 @@
use common_utils::{id_type, types::MinorUnit};
use masking::Secret;
use time::OffsetDateTime;
#[derive(serde::Serialize, Debug)]
pub struct RevenueRecovery<'a> {
pub merchant_id: &'a id_type::MerchantId,
pub invoice_amount: MinorUnit,
pub invoice_currency: &'a common_enums::Currency,
#[serde(default, with = "time::serde::timestamp::nanoseconds::option")]
pub invoice_due_date: Option<OffsetDateTime>,
#[serde(with = "time::serde::timestamp::nanoseconds::option")]
pub invoice_date: Option<OffsetDateTime>,
pub billing_country: Option<&'a common_enums::CountryAlpha2>,
pub billing_state: Option<Secret<String>>,
pub billing_city: Option<Secret<String>>,
pub attempt_amount: MinorUnit,
pub attempt_currency: &'a common_enums::Currency,
pub attempt_status: &'a common_enums::AttemptStatus,
pub pg_error_code: Option<String>,
pub network_advice_code: Option<String>,
pub network_error_code: Option<String>,
pub first_pg_error_code: Option<String>,
pub first_network_advice_code: Option<String>,
pub first_network_error_code: Option<String>,
#[serde(default, with = "time::serde::timestamp::nanoseconds")]
pub attempt_created_at: OffsetDateTime,
pub payment_method_type: Option<&'a common_enums::PaymentMethod>,
pub payment_method_subtype: Option<&'a common_enums::PaymentMethodType>,
pub card_network: Option<&'a common_enums::CardNetwork>,
pub card_issuer: Option<String>,
pub retry_count: Option<i32>,
pub payment_gateway: Option<common_enums::connector_enums::Connector>,
}
impl super::KafkaMessage for RevenueRecovery<'_> {
fn key(&self) -> String {
self.merchant_id.get_string_repr().to_string()
}
fn event_type(&self) -> crate::events::EventType {
crate::events::EventType::RevenueRecovery
}
}