From fc3c64fad37a4434f103fb0bcdf1eafe67441b56 Mon Sep 17 00:00:00 2001 From: Shankar Singh C <83439957+ShankarSinghC@users.noreply.github.com> Date: Fri, 4 Jul 2025 19:11:06 +0530 Subject: [PATCH] feat(debit_routing): add `debit_routing_savings` in analytics payment attempt (#8519) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- .../clickhouse/scripts/payment_attempts.sql | 4 + crates/analytics/src/payments/accumulator.rs | 34 ++++ crates/analytics/src/payments/core.rs | 30 ++++ crates/analytics/src/payments/metrics.rs | 12 ++ .../src/payments/metrics/debit_routing.rs | 151 +++++++++++++++++ .../payments/metrics/sessionized_metrics.rs | 2 + .../sessionized_metrics/debit_routing.rs | 152 ++++++++++++++++++ crates/api_models/src/analytics/payments.rs | 7 + crates/api_models/src/open_router.rs | 18 +-- crates/api_models/src/payment_methods.rs | 35 +++- crates/hyperswitch_domain_models/src/api.rs | 4 +- .../src/payment_method_data.rs | 22 ++- .../src/payments/payment_attempt.rs | 62 ++++++- crates/router/src/core/debit_routing.rs | 10 +- crates/router/src/core/payment_methods.rs | 15 +- .../src/core/payment_methods/tokenize.rs | 6 +- crates/router/src/core/payments.rs | 4 +- crates/router/src/core/payments/helpers.rs | 50 ++++++ .../payments/operations/payment_response.rs | 9 ++ crates/router/src/core/payments/retry.rs | 14 +- .../router/src/core/routing/transformers.rs | 2 +- crates/router/src/db/kafka_store.rs | 11 +- .../src/services/kafka/payment_attempt.rs | 2 + .../services/kafka/payment_attempt_event.rs | 2 + .../src/mock_db/payment_attempt.rs | 1 + .../src/payments/payment_attempt.rs | 2 + 26 files changed, 617 insertions(+), 44 deletions(-) create mode 100644 crates/analytics/src/payments/metrics/debit_routing.rs create mode 100644 crates/analytics/src/payments/metrics/sessionized_metrics/debit_routing.rs diff --git a/crates/analytics/docs/clickhouse/scripts/payment_attempts.sql b/crates/analytics/docs/clickhouse/scripts/payment_attempts.sql index 6673b73fed..16815e9a33 100644 --- a/crates/analytics/docs/clickhouse/scripts/payment_attempts.sql +++ b/crates/analytics/docs/clickhouse/scripts/payment_attempts.sql @@ -44,6 +44,7 @@ CREATE TABLE payment_attempt_queue ( `profile_id` String, `card_network` Nullable(String), `routing_approach` LowCardinality(Nullable(String)), + `debit_routing_savings` Nullable(UInt32), `sign_flag` Int8 ) ENGINE = Kafka SETTINGS kafka_broker_list = 'kafka0:29092', kafka_topic_list = 'hyperswitch-payment-attempt-events', @@ -98,6 +99,7 @@ CREATE TABLE payment_attempts ( `profile_id` String, `card_network` Nullable(String), `routing_approach` LowCardinality(Nullable(String)), + `debit_routing_savings` Nullable(UInt32), `sign_flag` Int8, INDEX connectorIndex connector TYPE bloom_filter GRANULARITY 1, INDEX paymentMethodIndex payment_method TYPE bloom_filter GRANULARITY 1, @@ -155,6 +157,7 @@ CREATE MATERIALIZED VIEW payment_attempt_mv TO payment_attempts ( `profile_id` String, `card_network` Nullable(String), `routing_approach` LowCardinality(Nullable(String)), + `debit_routing_savings` Nullable(UInt32), `sign_flag` Int8 ) AS SELECT @@ -204,6 +207,7 @@ SELECT profile_id, card_network, routing_approach, + debit_routing_savings, sign_flag FROM payment_attempt_queue diff --git a/crates/analytics/src/payments/accumulator.rs b/crates/analytics/src/payments/accumulator.rs index 20ccc63406..6d7dca22a3 100644 --- a/crates/analytics/src/payments/accumulator.rs +++ b/crates/analytics/src/payments/accumulator.rs @@ -18,6 +18,7 @@ pub struct PaymentMetricsAccumulator { pub connector_success_rate: SuccessRateAccumulator, pub payments_distribution: PaymentsDistributionAccumulator, pub failure_reasons_distribution: FailureReasonsDistributionAccumulator, + pub debit_routing: DebitRoutingAccumulator, } #[derive(Debug, Default)] @@ -58,6 +59,12 @@ pub struct ProcessedAmountAccumulator { pub total_without_retries: Option, } +#[derive(Debug, Default)] +pub struct DebitRoutingAccumulator { + pub transaction_count: u64, + pub savings_amount: u64, +} + #[derive(Debug, Default)] pub struct AverageAccumulator { pub total: u32, @@ -183,6 +190,27 @@ impl PaymentMetricAccumulator for SuccessRateAccumulator { } } +impl PaymentMetricAccumulator for DebitRoutingAccumulator { + type MetricOutput = (Option, Option, Option); + + fn add_metrics_bucket(&mut self, metrics: &PaymentMetricRow) { + if let Some(count) = metrics.count { + self.transaction_count += u64::try_from(count).unwrap_or(0); + } + if let Some(total) = metrics.total.as_ref().and_then(ToPrimitive::to_u64) { + self.savings_amount += total; + } + } + + fn collect(self) -> Self::MetricOutput { + ( + Some(self.transaction_count), + Some(self.savings_amount), + Some(0), + ) + } +} + impl PaymentMetricAccumulator for PaymentsDistributionAccumulator { type MetricOutput = ( Option, @@ -440,6 +468,9 @@ impl PaymentMetricsAccumulator { ) = self.payments_distribution.collect(); let (failure_reason_count, failure_reason_count_without_smart_retries) = self.failure_reasons_distribution.collect(); + let (debit_routed_transaction_count, debit_routing_savings, debit_routing_savings_in_usd) = + self.debit_routing.collect(); + PaymentMetricsBucketValue { payment_success_rate: self.payment_success_rate.collect(), payment_count: self.payment_count.collect(), @@ -463,6 +494,9 @@ impl PaymentMetricsAccumulator { failure_reason_count_without_smart_retries, payment_processed_amount_in_usd, payment_processed_amount_without_smart_retries_usd, + debit_routed_transaction_count, + debit_routing_savings, + debit_routing_savings_in_usd, } } } diff --git a/crates/analytics/src/payments/core.rs b/crates/analytics/src/payments/core.rs index e55ba2726a..c1922ffe92 100644 --- a/crates/analytics/src/payments/core.rs +++ b/crates/analytics/src/payments/core.rs @@ -171,6 +171,9 @@ pub async fn get_metrics( .connector_success_rate .add_metrics_bucket(&value); } + PaymentMetrics::DebitRouting | PaymentMetrics::SessionizedDebitRouting => { + metrics_builder.debit_routing.add_metrics_bucket(&value); + } PaymentMetrics::PaymentsDistribution => { metrics_builder .payments_distribution @@ -294,6 +297,33 @@ pub async fn get_metrics( if let Some(count) = collected_values.failure_reason_count_without_smart_retries { total_failure_reasons_count_without_smart_retries += count; } + if let Some(savings) = collected_values.debit_routing_savings { + let savings_in_usd = if let Some(ex_rates) = ex_rates { + id.currency + .and_then(|currency| { + i64::try_from(savings) + .inspect_err(|e| { + logger::error!( + "Debit Routing savings conversion error: {:?}", + e + ) + }) + .ok() + .and_then(|savings_i64| { + convert(ex_rates, currency, Currency::USD, savings_i64) + .inspect_err(|e| { + logger::error!("Currency conversion error: {:?}", e) + }) + .ok() + }) + }) + .map(|savings| (savings * rust_decimal::Decimal::new(100, 0)).to_u64()) + .unwrap_or_default() + } else { + None + }; + collected_values.debit_routing_savings_in_usd = savings_in_usd; + } MetricsBucketResponse { values: collected_values, dimensions: id, diff --git a/crates/analytics/src/payments/metrics.rs b/crates/analytics/src/payments/metrics.rs index 67dc50c515..b19c661322 100644 --- a/crates/analytics/src/payments/metrics.rs +++ b/crates/analytics/src/payments/metrics.rs @@ -15,6 +15,7 @@ use crate::{ mod avg_ticket_size; mod connector_success_rate; +mod debit_routing; mod payment_count; mod payment_processed_amount; mod payment_success_count; @@ -24,6 +25,7 @@ mod success_rate; use avg_ticket_size::AvgTicketSize; use connector_success_rate::ConnectorSuccessRate; +use debit_routing::DebitRouting; use payment_count::PaymentCount; use payment_processed_amount::PaymentProcessedAmount; use payment_success_count::PaymentSuccessCount; @@ -130,6 +132,11 @@ where .load_metrics(dimensions, auth, filters, granularity, time_range, pool) .await } + Self::DebitRouting => { + DebitRouting + .load_metrics(dimensions, auth, filters, granularity, time_range, pool) + .await + } Self::SessionizedPaymentSuccessRate => { sessionized_metrics::PaymentSuccessRate .load_metrics(dimensions, auth, filters, granularity, time_range, pool) @@ -175,6 +182,11 @@ where .load_metrics(dimensions, auth, filters, granularity, time_range, pool) .await } + Self::SessionizedDebitRouting => { + sessionized_metrics::DebitRouting + .load_metrics(dimensions, auth, filters, granularity, time_range, pool) + .await + } } } } diff --git a/crates/analytics/src/payments/metrics/debit_routing.rs b/crates/analytics/src/payments/metrics/debit_routing.rs new file mode 100644 index 0000000000..584221205c --- /dev/null +++ b/crates/analytics/src/payments/metrics/debit_routing.rs @@ -0,0 +1,151 @@ +use std::collections::HashSet; + +use api_models::analytics::{ + payments::{PaymentDimensions, PaymentFilters, PaymentMetricsBucketIdentifier}, + Granularity, TimeRange, +}; +use common_utils::errors::ReportSwitchExt; +use diesel_models::enums as storage_enums; +use error_stack::ResultExt; +use time::PrimitiveDateTime; + +use super::PaymentMetricRow; +use crate::{ + enums::AuthInfo, + query::{Aggregate, GroupByClause, QueryBuilder, QueryFilter, SeriesBucket, ToSql, Window}, + types::{AnalyticsCollection, AnalyticsDataSource, MetricsError, MetricsResult}, +}; + +#[derive(Default)] +pub(super) struct DebitRouting; + +#[async_trait::async_trait] +impl super::PaymentMetric for DebitRouting +where + T: AnalyticsDataSource + super::PaymentMetricAnalytics, + PrimitiveDateTime: ToSql, + AnalyticsCollection: ToSql, + Granularity: GroupByClause, + Aggregate<&'static str>: ToSql, + Window<&'static str>: ToSql, +{ + async fn load_metrics( + &self, + dimensions: &[PaymentDimensions], + auth: &AuthInfo, + filters: &PaymentFilters, + granularity: Option, + time_range: &TimeRange, + pool: &T, + ) -> MetricsResult> { + let mut query_builder: QueryBuilder = QueryBuilder::new(AnalyticsCollection::Payment); + + for dim in dimensions.iter() { + query_builder.add_select_column(dim).switch()?; + } + query_builder + .add_select_column(Aggregate::Count { + field: None, + alias: Some("count"), + }) + .switch()?; + query_builder + .add_select_column(Aggregate::Sum { + field: "debit_routing_savings", + alias: Some("total"), + }) + .switch()?; + query_builder.add_select_column("currency").switch()?; + query_builder + .add_select_column(Aggregate::Min { + field: "created_at", + alias: Some("start_bucket"), + }) + .switch()?; + query_builder + .add_select_column(Aggregate::Max { + field: "created_at", + alias: Some("end_bucket"), + }) + .switch()?; + + filters.set_filter_clause(&mut query_builder).switch()?; + + auth.set_filter_clause(&mut query_builder).switch()?; + + time_range + .set_filter_clause(&mut query_builder) + .attach_printable("Error filtering time range") + .switch()?; + + for dim in dimensions.iter() { + query_builder + .add_group_by_clause(dim) + .attach_printable("Error grouping by dimensions") + .switch()?; + } + + query_builder + .add_group_by_clause("currency") + .attach_printable("Error grouping by currency") + .switch()?; + + if let Some(granularity) = granularity { + granularity + .set_group_by_clause(&mut query_builder) + .attach_printable("Error adding granularity") + .switch()?; + } + + query_builder + .add_filter_clause( + PaymentDimensions::PaymentStatus, + storage_enums::AttemptStatus::Charged, + ) + .switch()?; + + query_builder + .execute_query::(pool) + .await + .change_context(MetricsError::QueryBuildingError)? + .change_context(MetricsError::QueryExecutionFailure)? + .into_iter() + .map(|i| { + Ok(( + PaymentMetricsBucketIdentifier::new( + i.currency.as_ref().map(|i| i.0), + None, + i.connector.clone(), + i.authentication_type.as_ref().map(|i| i.0), + i.payment_method.clone(), + i.payment_method_type.clone(), + i.client_source.clone(), + i.client_version.clone(), + i.profile_id.clone(), + i.card_network.clone(), + i.merchant_id.clone(), + i.card_last_4.clone(), + i.card_issuer.clone(), + i.error_reason.clone(), + i.routing_approach.as_ref().map(|i| i.0), + TimeRange { + start_time: match (granularity, i.start_bucket) { + (Some(g), Some(st)) => g.clip_to_start(st)?, + _ => time_range.start_time, + }, + end_time: granularity.as_ref().map_or_else( + || Ok(time_range.end_time), + |g| i.end_bucket.map(|et| g.clip_to_end(et)).transpose(), + )?, + }, + ), + i, + )) + }) + .collect::, + crate::query::PostProcessingError, + >>() + .change_context(MetricsError::PostProcessingFailure) + } +} diff --git a/crates/analytics/src/payments/metrics/sessionized_metrics.rs b/crates/analytics/src/payments/metrics/sessionized_metrics.rs index e3a5e37053..9d10c9db00 100644 --- a/crates/analytics/src/payments/metrics/sessionized_metrics.rs +++ b/crates/analytics/src/payments/metrics/sessionized_metrics.rs @@ -1,5 +1,6 @@ mod avg_ticket_size; mod connector_success_rate; +mod debit_routing; mod failure_reasons; mod payment_count; mod payment_processed_amount; @@ -9,6 +10,7 @@ mod retries_count; mod success_rate; pub(super) use avg_ticket_size::AvgTicketSize; pub(super) use connector_success_rate::ConnectorSuccessRate; +pub(super) use debit_routing::DebitRouting; pub(super) use failure_reasons::FailureReasons; pub(super) use payment_count::PaymentCount; pub(super) use payment_processed_amount::PaymentProcessedAmount; diff --git a/crates/analytics/src/payments/metrics/sessionized_metrics/debit_routing.rs b/crates/analytics/src/payments/metrics/sessionized_metrics/debit_routing.rs new file mode 100644 index 0000000000..f7c84e1b65 --- /dev/null +++ b/crates/analytics/src/payments/metrics/sessionized_metrics/debit_routing.rs @@ -0,0 +1,152 @@ +use std::collections::HashSet; + +use api_models::analytics::{ + payments::{PaymentDimensions, PaymentFilters, PaymentMetricsBucketIdentifier}, + Granularity, TimeRange, +}; +use common_utils::errors::ReportSwitchExt; +use diesel_models::enums as storage_enums; +use error_stack::ResultExt; +use time::PrimitiveDateTime; + +use super::PaymentMetricRow; +use crate::{ + enums::AuthInfo, + query::{Aggregate, GroupByClause, QueryBuilder, QueryFilter, SeriesBucket, ToSql, Window}, + types::{AnalyticsCollection, AnalyticsDataSource, MetricsError, MetricsResult}, +}; + +#[derive(Default)] +pub(crate) struct DebitRouting; + +#[async_trait::async_trait] +impl super::PaymentMetric for DebitRouting +where + T: AnalyticsDataSource + super::PaymentMetricAnalytics, + PrimitiveDateTime: ToSql, + AnalyticsCollection: ToSql, + Granularity: GroupByClause, + Aggregate<&'static str>: ToSql, + Window<&'static str>: ToSql, +{ + async fn load_metrics( + &self, + dimensions: &[PaymentDimensions], + auth: &AuthInfo, + filters: &PaymentFilters, + granularity: Option, + time_range: &TimeRange, + pool: &T, + ) -> MetricsResult> { + let mut query_builder: QueryBuilder = + QueryBuilder::new(AnalyticsCollection::PaymentSessionized); + + for dim in dimensions.iter() { + query_builder.add_select_column(dim).switch()?; + } + query_builder + .add_select_column(Aggregate::Count { + field: None, + alias: Some("count"), + }) + .switch()?; + query_builder + .add_select_column(Aggregate::Sum { + field: "debit_routing_savings", + alias: Some("total"), + }) + .switch()?; + query_builder.add_select_column("currency").switch()?; + query_builder + .add_select_column(Aggregate::Min { + field: "created_at", + alias: Some("start_bucket"), + }) + .switch()?; + query_builder + .add_select_column(Aggregate::Max { + field: "created_at", + alias: Some("end_bucket"), + }) + .switch()?; + + filters.set_filter_clause(&mut query_builder).switch()?; + + auth.set_filter_clause(&mut query_builder).switch()?; + + time_range + .set_filter_clause(&mut query_builder) + .attach_printable("Error filtering time range") + .switch()?; + + for dim in dimensions.iter() { + query_builder + .add_group_by_clause(dim) + .attach_printable("Error grouping by dimensions") + .switch()?; + } + + query_builder + .add_group_by_clause("currency") + .attach_printable("Error grouping by currency") + .switch()?; + + if let Some(granularity) = granularity { + granularity + .set_group_by_clause(&mut query_builder) + .attach_printable("Error adding granularity") + .switch()?; + } + + query_builder + .add_filter_clause( + PaymentDimensions::PaymentStatus, + storage_enums::AttemptStatus::Charged, + ) + .switch()?; + + query_builder + .execute_query::(pool) + .await + .change_context(MetricsError::QueryBuildingError)? + .change_context(MetricsError::QueryExecutionFailure)? + .into_iter() + .map(|i| { + Ok(( + PaymentMetricsBucketIdentifier::new( + i.currency.as_ref().map(|i| i.0), + None, + i.connector.clone(), + i.authentication_type.as_ref().map(|i| i.0), + i.payment_method.clone(), + i.payment_method_type.clone(), + i.client_source.clone(), + i.client_version.clone(), + i.profile_id.clone(), + i.card_network.clone(), + i.merchant_id.clone(), + i.card_last_4.clone(), + i.card_issuer.clone(), + i.error_reason.clone(), + i.routing_approach.as_ref().map(|i| i.0), + TimeRange { + start_time: match (granularity, i.start_bucket) { + (Some(g), Some(st)) => g.clip_to_start(st)?, + _ => time_range.start_time, + }, + end_time: granularity.as_ref().map_or_else( + || Ok(time_range.end_time), + |g| i.end_bucket.map(|et| g.clip_to_end(et)).transpose(), + )?, + }, + ), + i, + )) + }) + .collect::, + crate::query::PostProcessingError, + >>() + .change_context(MetricsError::PostProcessingFailure) + } +} diff --git a/crates/api_models/src/analytics/payments.rs b/crates/api_models/src/analytics/payments.rs index 537d6df09d..ec5719f1ad 100644 --- a/crates/api_models/src/analytics/payments.rs +++ b/crates/api_models/src/analytics/payments.rs @@ -111,6 +111,7 @@ pub enum PaymentMetrics { AvgTicketSize, RetriesCount, ConnectorSuccessRate, + DebitRouting, SessionizedPaymentSuccessRate, SessionizedPaymentCount, SessionizedPaymentSuccessCount, @@ -118,6 +119,7 @@ pub enum PaymentMetrics { SessionizedAvgTicketSize, SessionizedRetriesCount, SessionizedConnectorSuccessRate, + SessionizedDebitRouting, PaymentsDistribution, FailureReasons, } @@ -128,8 +130,10 @@ impl ForexMetric for PaymentMetrics { self, Self::PaymentProcessedAmount | Self::AvgTicketSize + | Self::DebitRouting | Self::SessionizedPaymentProcessedAmount | Self::SessionizedAvgTicketSize + | Self::SessionizedDebitRouting, ) } } @@ -309,6 +313,9 @@ pub struct PaymentMetricsBucketValue { pub payments_failure_rate_distribution_with_only_retries: Option, pub failure_reason_count: Option, pub failure_reason_count_without_smart_retries: Option, + pub debit_routed_transaction_count: Option, + pub debit_routing_savings: Option, + pub debit_routing_savings_in_usd: Option, } #[derive(Debug, serde::Serialize)] diff --git a/crates/api_models/src/open_router.rs b/crates/api_models/src/open_router.rs index ba53f4c1f0..f5b80383bf 100644 --- a/crates/api_models/src/open_router.rs +++ b/crates/api_models/src/open_router.rs @@ -67,7 +67,7 @@ pub struct DecidedGateway { #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub struct DebitRoutingOutput { - pub co_badged_card_networks_info: Vec, + pub co_badged_card_networks_info: CoBadgedCardNetworks, pub issuer_country: common_enums::CountryAlpha2, pub is_regulated: bool, pub regulated_name: Option, @@ -80,19 +80,19 @@ pub struct CoBadgedCardNetworksInfo { pub saving_percentage: f64, } -impl DebitRoutingOutput { - pub fn get_co_badged_card_networks(&self) -> Vec { - self.co_badged_card_networks_info - .iter() - .map(|data| data.network.clone()) - .collect() +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct CoBadgedCardNetworks(pub Vec); + +impl CoBadgedCardNetworks { + pub fn get_card_networks(&self) -> Vec { + self.0.iter().map(|info| info.network.clone()).collect() } } impl From<&DebitRoutingOutput> for payment_methods::CoBadgedCardData { fn from(output: &DebitRoutingOutput) -> Self { Self { - co_badged_card_networks: output.get_co_badged_card_networks(), + co_badged_card_networks_info: output.co_badged_card_networks_info.clone(), issuer_country_code: output.issuer_country, is_regulated: output.is_regulated, regulated_name: output.regulated_name.clone(), @@ -111,7 +111,7 @@ impl TryFrom<(payment_methods::CoBadgedCardData, String)> for DebitRoutingReques })?; Ok(Self { - co_badged_card_networks_info: output.co_badged_card_networks, + co_badged_card_networks_info: output.co_badged_card_networks_info.get_card_networks(), issuer_country: output.issuer_country_code, is_regulated: output.is_regulated, regulated_name: output.regulated_name, diff --git a/crates/api_models/src/payment_methods.rs b/crates/api_models/src/payment_methods.rs index 6a8f61484d..0745fade98 100644 --- a/crates/api_models/src/payment_methods.rs +++ b/crates/api_models/src/payment_methods.rs @@ -19,7 +19,7 @@ use utoipa::{schema, ToSchema}; #[cfg(feature = "payouts")] use crate::payouts; use crate::{ - admin, enums as api_enums, + admin, enums as api_enums, open_router, payments::{self, BankCodeResponse}, }; @@ -937,14 +937,14 @@ pub struct PaymentMethodResponse { pub network_token: Option, } -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub enum PaymentMethodsData { Card(CardDetailsPaymentMethod), BankDetails(PaymentMethodDataBankCreds), WalletDetails(PaymentMethodDataWalletInfo), } -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct CardDetailsPaymentMethod { pub last4_digits: Option, pub issuer_country: Option, @@ -958,12 +958,33 @@ pub struct CardDetailsPaymentMethod { pub card_type: Option, #[serde(default = "saved_in_locker_default")] pub saved_to_locker: bool, - pub co_badged_card_data: Option, + pub co_badged_card_data: Option, } -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +impl From<&CoBadgedCardData> for CoBadgedCardDataToBeSaved { + fn from(co_badged_card_data: &CoBadgedCardData) -> Self { + Self { + co_badged_card_networks: co_badged_card_data + .co_badged_card_networks_info + .get_card_networks(), + issuer_country_code: co_badged_card_data.issuer_country_code, + is_regulated: co_badged_card_data.is_regulated, + regulated_name: co_badged_card_data.regulated_name.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct CoBadgedCardData { - pub co_badged_card_networks: Vec, + pub co_badged_card_networks_info: open_router::CoBadgedCardNetworks, + pub issuer_country_code: common_enums::CountryAlpha2, + pub is_regulated: bool, + pub regulated_name: Option, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct CoBadgedCardDataToBeSaved { + pub co_badged_card_networks: Vec, pub issuer_country_code: common_enums::CountryAlpha2, pub is_regulated: bool, pub regulated_name: Option, @@ -1325,7 +1346,7 @@ impl From<(CardDetailFromLocker, Option<&CoBadgedCardData>)> for CardDetailsPaym card_network: item.card_network, card_type: item.card_type, saved_to_locker: item.saved_to_locker, - co_badged_card_data: co_badged_card_data.cloned(), + co_badged_card_data: co_badged_card_data.map(CoBadgedCardDataToBeSaved::from), } } } diff --git a/crates/hyperswitch_domain_models/src/api.rs b/crates/hyperswitch_domain_models/src/api.rs index f6d9349762..723cc458e7 100644 --- a/crates/hyperswitch_domain_models/src/api.rs +++ b/crates/hyperswitch_domain_models/src/api.rs @@ -7,7 +7,7 @@ use common_utils::{ use super::payment_method_data::PaymentMethodData; -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, PartialEq)] pub enum ApplicationResponse { Json(R), StatusOk, @@ -54,7 +54,7 @@ impl ApiEventMetric for ApplicationResponse { impl_api_event_type!(Miscellaneous, (PaymentLinkFormData, GenericLinkFormData)); -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, PartialEq)] pub struct RedirectionFormData { pub redirect_form: crate::router_response_types::RedirectForm, pub payment_method_data: Option, diff --git a/crates/hyperswitch_domain_models/src/payment_method_data.rs b/crates/hyperswitch_domain_models/src/payment_method_data.rs index 5433c486fb..7a42001065 100644 --- a/crates/hyperswitch_domain_models/src/payment_method_data.rs +++ b/crates/hyperswitch_domain_models/src/payment_method_data.rs @@ -24,7 +24,7 @@ use time::Date; // We need to derive Serialize and Deserialize because some parts of payment method data are being // stored in the database as serde_json::Value -#[derive(Eq, PartialEq, Clone, Debug, Serialize, Deserialize)] +#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] pub enum PaymentMethodData { Card(Card), CardDetailsForNetworkTransactionId(CardDetailsForNetworkTransactionId), @@ -88,9 +88,21 @@ impl PaymentMethodData { None } } + + pub fn extract_debit_routing_saving_percentage( + &self, + network: &common_enums::CardNetwork, + ) -> Option { + self.get_co_badged_card_data()? + .co_badged_card_networks_info + .0 + .iter() + .find(|info| &info.network == network) + .map(|info| info.saving_percentage) + } } -#[derive(Eq, PartialEq, Clone, Debug, Serialize, Deserialize, Default)] +#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, Default)] pub struct Card { pub card_number: cards::CardNumber, pub card_exp_month: Secret, @@ -120,7 +132,7 @@ pub struct CardDetailsForNetworkTransactionId { pub card_holder_name: Option>, } -#[derive(Eq, PartialEq, Clone, Debug, Serialize, Deserialize, Default)] +#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, Default)] pub struct CardDetail { pub card_number: cards::CardNumber, pub card_exp_month: Secret, @@ -1961,7 +1973,7 @@ impl From for payment_methods::PaymentMethodDataWalletInfo } } -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub enum PaymentMethodsData { Card(CardDetailsPaymentMethod), BankDetails(payment_methods::PaymentMethodDataBankCreds), //PaymentMethodDataBankCreds and its transformations should be moved to the domain models @@ -2005,7 +2017,7 @@ fn saved_in_locker_default() -> bool { } #[cfg(feature = "v1")] -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] pub struct CardDetailsPaymentMethod { pub last4_digits: Option, pub issuer_country: Option, diff --git a/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs b/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs index 2b568b70af..3baddfa98c 100644 --- a/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs +++ b/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs @@ -9,14 +9,12 @@ use common_types::primitive_wrappers::{ }; #[cfg(feature = "v2")] use common_utils::{ - crypto::Encryptable, - encryption::Encryption, - ext_traits::{Encode, ValueExt}, + crypto::Encryptable, encryption::Encryption, ext_traits::Encode, types::keymanager::ToEncryptable, }; use common_utils::{ errors::{CustomResult, ValidationError}, - ext_traits::OptionExt, + ext_traits::{OptionExt, ValueExt}, id_type, pii, types::{ keymanager::{self, KeyManagerState}, @@ -906,6 +904,7 @@ pub struct PaymentAttempt { pub setup_future_usage_applied: Option, pub routing_approach: Option, pub connector_request_reference_id: Option, + pub debit_routing_savings: Option, } #[cfg(feature = "v1")] @@ -1063,6 +1062,10 @@ impl PaymentAttempt { pub fn get_total_surcharge_amount(&self) -> Option { self.amount_details.surcharge_amount } + + pub fn extract_card_network(&self) -> Option { + todo!() + } } #[cfg(feature = "v1")] @@ -1074,6 +1077,25 @@ impl PaymentAttempt { pub fn get_total_surcharge_amount(&self) -> Option { self.net_amount.get_total_surcharge_amount() } + + pub fn set_debit_routing_savings(&mut self, debit_routing_savings: Option<&MinorUnit>) { + self.debit_routing_savings = debit_routing_savings.copied(); + } + + pub fn extract_card_network(&self) -> Option { + self.payment_method_data + .as_ref() + .and_then(|value| { + value + .clone() + .parse_value::( + "AdditionalPaymentData", + ) + .ok() + }) + .and_then(|data| data.get_additional_card_info()) + .and_then(|card_info| card_info.card_network) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1283,6 +1305,7 @@ pub enum PaymentAttemptUpdate { connector_mandate_detail: Option, charges: Option, setup_future_usage_applied: Option, + debit_routing_savings: Option, }, UnresolvedResponseUpdate { status: storage_enums::AttemptStatus, @@ -1566,6 +1589,7 @@ impl PaymentAttemptUpdate { connector_mandate_detail, charges, setup_future_usage_applied, + debit_routing_savings: _, } => DieselPaymentAttemptUpdate::ResponseUpdate { status, connector, @@ -1756,6 +1780,35 @@ impl PaymentAttemptUpdate { }, } } + + pub fn get_debit_routing_savings(&self) -> Option<&MinorUnit> { + match self { + Self::ResponseUpdate { + debit_routing_savings, + .. + } => debit_routing_savings.as_ref(), + Self::Update { .. } + | Self::UpdateTrackers { .. } + | Self::AuthenticationTypeUpdate { .. } + | Self::ConfirmUpdate { .. } + | Self::RejectUpdate { .. } + | Self::BlocklistUpdate { .. } + | Self::PaymentMethodDetailsUpdate { .. } + | Self::ConnectorMandateDetailUpdate { .. } + | Self::VoidUpdate { .. } + | Self::UnresolvedResponseUpdate { .. } + | Self::StatusUpdate { .. } + | Self::ErrorUpdate { .. } + | Self::CaptureUpdate { .. } + | Self::AmountToCaptureUpdate { .. } + | Self::PreprocessingUpdate { .. } + | Self::ConnectorResponse { .. } + | Self::IncrementalAuthorizationAmountUpdate { .. } + | Self::AuthenticationUpdate { .. } + | Self::ManualUpdate { .. } + | Self::PostSessionTokensUpdate { .. } => None, + } + } } #[cfg(feature = "v2")] @@ -2033,6 +2086,7 @@ impl behaviour::Conversion for PaymentAttempt { setup_future_usage_applied: storage_model.setup_future_usage_applied, routing_approach: storage_model.routing_approach, connector_request_reference_id: storage_model.connector_request_reference_id, + debit_routing_savings: None, }) } .await diff --git a/crates/router/src/core/debit_routing.rs b/crates/router/src/core/debit_routing.rs index 4975ffc4bd..d4f7c8bd75 100644 --- a/crates/router/src/core/debit_routing.rs +++ b/crates/router/src/core/debit_routing.rs @@ -281,7 +281,10 @@ where &profile_id, &key_store, vec![connector_data.clone()], - debit_routing_output.get_co_badged_card_networks(), + debit_routing_output + .co_badged_card_networks_info + .clone() + .get_card_networks(), ) .await .map_err(|error| { @@ -454,7 +457,10 @@ where &profile_id, &key_store, connector_data_list.clone(), - debit_routing_output.get_co_badged_card_networks(), + debit_routing_output + .co_badged_card_networks_info + .clone() + .get_card_networks(), ) .await .map_err(|error| { diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs index 4b1cff09be..e8e5b2040e 100644 --- a/crates/router/src/core/payment_methods.rs +++ b/crates/router/src/core/payment_methods.rs @@ -865,12 +865,15 @@ fn get_card_network_with_us_local_debit_network_override( .map(|network| network.is_us_local_network()) { services::logger::debug!("Card network is a US local network, checking for global network in co-badged card data"); - co_badged_card_data.and_then(|data| { - data.co_badged_card_networks - .iter() - .find(|network| network.is_global_network()) - .cloned() - }) + let info: Option = co_badged_card_data + .and_then(|data| { + data.co_badged_card_networks_info + .0 + .iter() + .find(|info| info.network.is_global_network()) + .cloned() + }); + info.map(|data| data.network) } else { card_network } diff --git a/crates/router/src/core/payment_methods/tokenize.rs b/crates/router/src/core/payment_methods/tokenize.rs index 3556673510..f0eaea45d9 100644 --- a/crates/router/src/core/payment_methods/tokenize.rs +++ b/crates/router/src/core/payment_methods/tokenize.rs @@ -272,8 +272,12 @@ where card_network: card_details.card_network.clone(), card_type: card_details.card_type.clone(), saved_to_locker, - co_badged_card_data: card_details.co_badged_card_data.clone(), + co_badged_card_data: card_details + .co_badged_card_data + .as_ref() + .map(|data| data.into()), }); + create_encrypted_data(&self.state.into(), self.key_store, pm_data) .await .inspect_err(|err| logger::info!("Error encrypting payment method data: {:?}", err)) diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index b12704aab1..1d5e2a7b4a 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -471,7 +471,7 @@ where // To perform router related operation for PaymentResponse PaymentResponse: Operation, - FData: Send + Sync + Clone, + FData: Send + Sync + Clone + router_types::Capturable, { let operation: BoxedOperation<'_, F, Req, D> = Box::new(operation); @@ -1844,7 +1844,7 @@ pub async fn payments_core( ) -> RouterResponse where F: Send + Clone + Sync, - FData: Send + Sync + Clone, + FData: Send + Sync + Clone + router_types::Capturable, Op: Operation + Send + Sync + Clone, Req: Debug + Authenticate + Clone, D: OperationSessionGetters + OperationSessionSetters + Send + Sync + Clone, diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index d855ca84e1..9065dcd2ce 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -44,6 +44,7 @@ use hyperswitch_domain_models::{ use hyperswitch_interfaces::integrity::{CheckIntegrity, FlowIntegrity, GetIntegrityObject}; use josekit::jwe; use masking::{ExposeInterface, PeekInterface, SwitchStrategy}; +use num_traits::{FromPrimitive, ToPrimitive}; use openssl::{ derive::Deriver, pkey::PKey, @@ -52,6 +53,7 @@ use openssl::{ #[cfg(feature = "v2")] use redis_interface::errors::RedisError; use router_env::{instrument, logger, tracing}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use uuid::Uuid; use x509_parser::parse_x509_certificate; @@ -5208,6 +5210,54 @@ pub fn get_applepay_metadata( }) } +pub fn calculate_debit_routing_savings(net_amount: i64, saving_percentage: f64) -> MinorUnit { + logger::debug!( + ?net_amount, + ?saving_percentage, + "Calculating debit routing saving amount" + ); + + let net_decimal = Decimal::from_i64(net_amount).unwrap_or_else(|| { + logger::warn!(?net_amount, "Invalid net_amount, using 0"); + Decimal::ZERO + }); + + let percentage_decimal = Decimal::from_f64(saving_percentage).unwrap_or_else(|| { + logger::warn!(?saving_percentage, "Invalid saving_percentage, using 0"); + Decimal::ZERO + }); + + let savings_decimal = net_decimal * percentage_decimal / Decimal::from(100); + let rounded_savings = savings_decimal.round(); + + let savings_int = rounded_savings.to_i64().unwrap_or_else(|| { + logger::warn!( + ?rounded_savings, + "Debit routing savings calculation overflowed when converting to i64" + ); + 0 + }); + + MinorUnit::new(savings_int) +} + +pub fn get_debit_routing_savings_amount( + payment_method_data: &domain::PaymentMethodData, + payment_attempt: &PaymentAttempt, +) -> Option { + let card_network = payment_attempt.extract_card_network()?; + + let saving_percentage = + payment_method_data.extract_debit_routing_saving_percentage(&card_network)?; + + let net_amount = payment_attempt.get_total_amount().get_amount_as_i64(); + + Some(calculate_debit_routing_savings( + net_amount, + saving_percentage, + )) +} + #[cfg(all(feature = "retry", feature = "v1"))] pub async fn get_apple_pay_retryable_connectors( state: &SessionState, diff --git a/crates/router/src/core/payments/operations/payment_response.rs b/crates/router/src/core/payments/operations/payment_response.rs index 939679ef51..e72555b2ed 100644 --- a/crates/router/src/core/payments/operations/payment_response.rs +++ b/crates/router/src/core/payments/operations/payment_response.rs @@ -1767,6 +1767,14 @@ async fn payment_response_update_tracker( let payment_method_id = payment_data.payment_attempt.payment_method_id.clone(); + let debit_routing_savings = + payment_data.payment_method_data.as_ref().and_then(|data| { + payments_helpers::get_debit_routing_savings_amount( + data, + &payment_data.payment_attempt, + ) + }); + utils::add_apple_pay_payment_status_metrics( router_data.status, router_data.apple_pay_flow.clone(), @@ -1857,6 +1865,7 @@ async fn payment_response_update_tracker( setup_future_usage_applied: payment_data .payment_attempt .setup_future_usage_applied, + debit_routing_savings, }), ), }; diff --git a/crates/router/src/core/payments/retry.rs b/crates/router/src/core/payments/retry.rs index 728396ef53..f7d8b3e888 100644 --- a/crates/router/src/core/payments/retry.rs +++ b/crates/router/src/core/payments/retry.rs @@ -48,7 +48,7 @@ pub async fn do_gsm_actions( ) -> RouterResult> where F: Clone + Send + Sync, - FData: Send + Sync, + FData: Send + Sync + types::Capturable, payments::PaymentResponse: operations::Operation, D: payments::OperationSessionGetters + payments::OperationSessionSetters @@ -345,7 +345,7 @@ pub async fn do_retry( ) -> RouterResult> where F: Clone + Send + Sync, - FData: Send + Sync, + FData: Send + Sync + types::Capturable, payments::PaymentResponse: operations::Operation, D: payments::OperationSessionGetters + payments::OperationSessionSetters @@ -425,7 +425,7 @@ pub async fn modify_trackers( ) -> RouterResult<()> where F: Clone + Send, - FData: Send, + FData: Send + types::Capturable, D: payments::OperationSessionGetters + payments::OperationSessionSetters + Send + Sync, { let new_attempt_count = payment_data.get_payment_intent().attempt_count + 1; @@ -451,6 +451,13 @@ where .and_then(|connector_response| connector_response.additional_payment_method_data), )?; + let debit_routing_savings = payment_data.get_payment_method_data().and_then(|data| { + payments::helpers::get_debit_routing_savings_amount( + data, + payment_data.get_payment_attempt(), + ) + }); + match router_data.response { Ok(types::PaymentsResponseData::TransactionResponse { resource_id, @@ -506,6 +513,7 @@ where connector_mandate_detail: None, charges, setup_future_usage_applied: None, + debit_routing_savings, }; #[cfg(feature = "v1")] diff --git a/crates/router/src/core/routing/transformers.rs b/crates/router/src/core/routing/transformers.rs index 6560e6786c..b13dee1bcb 100644 --- a/crates/router/src/core/routing/transformers.rs +++ b/crates/router/src/core/routing/transformers.rs @@ -180,7 +180,7 @@ impl OpenRouterDecideGatewayRequestExt for OpenRouterDecideGatewayRequest { Self { payment_info: PaymentInfo { payment_id: attempt.payment_id.clone(), - amount: attempt.net_amount.get_order_amount(), + amount: attempt.net_amount.get_total_amount(), currency: attempt.currency.unwrap_or(storage_enums::Currency::USD), payment_type: "ORDER_PAYMENT".to_string(), card_isin: card_isin.map(|value| value.peek().clone()), diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index 7f24d2f5c9..68af56f226 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -1520,11 +1520,18 @@ impl PaymentAttemptInterface for KafkaStore { payment_attempt: storage::PaymentAttemptUpdate, storage_scheme: MerchantStorageScheme, ) -> CustomResult { - let attempt = self + let mut attempt = self .diesel_store - .update_payment_attempt_with_attempt_id(this.clone(), payment_attempt, storage_scheme) + .update_payment_attempt_with_attempt_id( + this.clone(), + payment_attempt.clone(), + storage_scheme, + ) .await?; + let debit_routing_savings = payment_attempt.get_debit_routing_savings(); + + attempt.set_debit_routing_savings(debit_routing_savings); if let Err(er) = self .kafka_producer .log_payment_attempt(&attempt, Some(this), self.tenant_id.clone()) diff --git a/crates/router/src/services/kafka/payment_attempt.rs b/crates/router/src/services/kafka/payment_attempt.rs index f51e5a12d2..e0dbc6ad21 100644 --- a/crates/router/src/services/kafka/payment_attempt.rs +++ b/crates/router/src/services/kafka/payment_attempt.rs @@ -70,6 +70,7 @@ pub struct KafkaPaymentAttempt<'a> { pub card_network: Option, pub card_discovery: Option, pub routing_approach: Option, + pub debit_routing_savings: Option, } #[cfg(feature = "v1")] @@ -132,6 +133,7 @@ impl<'a> KafkaPaymentAttempt<'a> { .card_discovery .map(|discovery| discovery.to_string()), routing_approach: attempt.routing_approach, + debit_routing_savings: attempt.debit_routing_savings, } } } diff --git a/crates/router/src/services/kafka/payment_attempt_event.rs b/crates/router/src/services/kafka/payment_attempt_event.rs index e2a88e2c29..a54ff3cc28 100644 --- a/crates/router/src/services/kafka/payment_attempt_event.rs +++ b/crates/router/src/services/kafka/payment_attempt_event.rs @@ -71,6 +71,7 @@ pub struct KafkaPaymentAttemptEvent<'a> { pub card_network: Option, pub card_discovery: Option, pub routing_approach: Option, + pub debit_routing_savings: Option, } #[cfg(feature = "v1")] @@ -133,6 +134,7 @@ impl<'a> KafkaPaymentAttemptEvent<'a> { .card_discovery .map(|discovery| discovery.to_string()), routing_approach: attempt.routing_approach, + debit_routing_savings: attempt.debit_routing_savings, } } } diff --git a/crates/storage_impl/src/mock_db/payment_attempt.rs b/crates/storage_impl/src/mock_db/payment_attempt.rs index 88ddc824b2..ec27f9bed6 100644 --- a/crates/storage_impl/src/mock_db/payment_attempt.rs +++ b/crates/storage_impl/src/mock_db/payment_attempt.rs @@ -237,6 +237,7 @@ impl PaymentAttemptInterface for MockDb { setup_future_usage_applied: payment_attempt.setup_future_usage_applied, routing_approach: payment_attempt.routing_approach, connector_request_reference_id: payment_attempt.connector_request_reference_id, + debit_routing_savings: None, }; payment_attempts.push(payment_attempt.clone()); Ok(payment_attempt) diff --git a/crates/storage_impl/src/payments/payment_attempt.rs b/crates/storage_impl/src/payments/payment_attempt.rs index 2c69c0766b..b3fccea353 100644 --- a/crates/storage_impl/src/payments/payment_attempt.rs +++ b/crates/storage_impl/src/payments/payment_attempt.rs @@ -689,6 +689,7 @@ impl PaymentAttemptInterface for KVRouterStore { connector_request_reference_id: payment_attempt .connector_request_reference_id .clone(), + debit_routing_savings: None, }; let field = format!("pa_{}", created_attempt.attempt_id); @@ -1989,6 +1990,7 @@ impl DataModelExt for PaymentAttempt { setup_future_usage_applied: storage_model.setup_future_usage_applied, routing_approach: storage_model.routing_approach, connector_request_reference_id: storage_model.connector_request_reference_id, + debit_routing_savings: None, } } }