mirror of
https://github.com/juspay/hyperswitch.git
synced 2026-03-13 09:02:06 +08:00
feat(revenue_recovery): Introduce hourly retry history and decision threshold in Decider Request (#10386)
Co-authored-by: Aniket Burman <aniket.burman@Aniket-Burman-JDXHW2PH34.local> Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -97,7 +97,7 @@ pub struct ComprehensiveCardData {
|
||||
pub card_network: Option<CardNetwork>,
|
||||
pub card_issuer: Option<String>,
|
||||
pub card_issuing_country: Option<String>,
|
||||
pub daily_retry_history: Option<HashMap<Date, i32>>,
|
||||
pub daily_retry_history: Option<HashMap<PrimitiveDateTime, i32>>,
|
||||
pub is_active: Option<bool>,
|
||||
pub account_update_history: Option<Vec<AccountUpdateHistoryRecord>>,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use error_stack::ResultExt;
|
||||
use hyperswitch_domain_models::api::ApplicationResponse;
|
||||
use masking::ExposeInterface;
|
||||
use router_env::{instrument, logger};
|
||||
use time::{format_description, Date};
|
||||
use time::{macros::format_description, Date};
|
||||
|
||||
use crate::{
|
||||
connection,
|
||||
@@ -333,43 +333,46 @@ async fn process_payment_method_record(
|
||||
}
|
||||
|
||||
/// Parse daily retry history from CSV
|
||||
fn parse_daily_retry_history(json_str: Option<&str>) -> Option<HashMap<Date, i32>> {
|
||||
fn parse_daily_retry_history(
|
||||
json_str: Option<&str>,
|
||||
) -> Option<HashMap<time::PrimitiveDateTime, i32>> {
|
||||
match json_str {
|
||||
Some(json) if !json.is_empty() => {
|
||||
match serde_json::from_str::<HashMap<String, i32>>(json) {
|
||||
Ok(string_retry_history) => {
|
||||
// Convert string dates to Date objects
|
||||
let format = format_description::parse("[year]-[month]-[day]")
|
||||
.map_err(|e| {
|
||||
BackfillError::CsvParsingError(format!(
|
||||
"Invalid date format configuration: {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
.ok()?;
|
||||
let date_format = format_description!("[year]-[month]-[day]");
|
||||
let datetime_format = format_description!(
|
||||
"[year]-[month]-[day] [hour]:[minute]:[second].[subsecond]"
|
||||
);
|
||||
|
||||
let mut date_retry_history = HashMap::new();
|
||||
let mut hourly_retry_history = HashMap::new();
|
||||
|
||||
for (date_str, count) in string_retry_history {
|
||||
match Date::parse(&date_str, &format) {
|
||||
Ok(date) => {
|
||||
date_retry_history.insert(date, count);
|
||||
for (key, count) in string_retry_history {
|
||||
// Try parsing full datetime first
|
||||
let parsed_dt = time::PrimitiveDateTime::parse(&key, &datetime_format)
|
||||
.or_else(|_| {
|
||||
// Fallback to date only
|
||||
Date::parse(&key, &date_format).map(|date| {
|
||||
time::PrimitiveDateTime::new(date, time::Time::MIDNIGHT)
|
||||
})
|
||||
});
|
||||
|
||||
match parsed_dt {
|
||||
Ok(dt) => {
|
||||
hourly_retry_history.insert(dt, count);
|
||||
}
|
||||
Err(e) => {
|
||||
logger::warn!(
|
||||
"Failed to parse date '{}' in daily_retry_history: {}",
|
||||
date_str,
|
||||
e
|
||||
);
|
||||
Err(_) => {
|
||||
logger::error!("Error: failed to parse retry history key '{}'", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger::debug!(
|
||||
"Successfully parsed daily_retry_history with {} entries",
|
||||
date_retry_history.len()
|
||||
hourly_retry_history.len()
|
||||
);
|
||||
Some(date_retry_history)
|
||||
|
||||
Some(hourly_retry_history)
|
||||
}
|
||||
Err(e) => {
|
||||
logger::warn!("Failed to parse daily_retry_history JSON '{}': {}", json, e);
|
||||
|
||||
@@ -929,6 +929,12 @@ impl RevenueRecoveryAttempt {
|
||||
.map(|category| category == common_enums::ErrorCategory::HardDecline)
|
||||
.unwrap_or(false);
|
||||
|
||||
let reference_time = time::PrimitiveDateTime::new(
|
||||
recovery_attempt.created_at.date(),
|
||||
time::Time::from_hms(recovery_attempt.created_at.hour(), 0, 0)
|
||||
.unwrap_or(time::Time::MIDNIGHT),
|
||||
);
|
||||
|
||||
// Extract required fields from the revenue recovery attempt data
|
||||
let connector_customer_id = revenue_recovery_attempt_data.connector_customer_id.clone();
|
||||
|
||||
@@ -936,7 +942,7 @@ impl RevenueRecoveryAttempt {
|
||||
let token_unit = PaymentProcessorTokenStatus {
|
||||
error_code,
|
||||
inserted_by_attempt_id: attempt_id.clone(),
|
||||
daily_retry_history: HashMap::from([(recovery_attempt.created_at.date(), 1)]),
|
||||
daily_retry_history: HashMap::from([(reference_time, 1)]),
|
||||
scheduled_at: None,
|
||||
is_hard_decline: Some(is_hard_decline),
|
||||
modified_at: Some(recovery_attempt.created_at),
|
||||
@@ -960,6 +966,7 @@ impl RevenueRecoveryAttempt {
|
||||
},
|
||||
is_active: Some(true), // Tokens created from recovery attempts are active by default
|
||||
account_update_history: None, // No prior account update history exists for freshly ingested tokens
|
||||
decision_threshold: None,
|
||||
};
|
||||
|
||||
// Make the Redis call to store tokens
|
||||
|
||||
@@ -7,14 +7,14 @@ use error_stack::ResultExt;
|
||||
use masking::{ExposeInterface, PeekInterface, Secret};
|
||||
use redis_interface::{DelReply, SetnxReply};
|
||||
use router_env::{instrument, logger, tracing};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Date, Duration, OffsetDateTime, PrimitiveDateTime};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use time::{Date, Duration, OffsetDateTime, PrimitiveDateTime, Time};
|
||||
|
||||
use crate::{db::errors, types::storage::enums::RevenueRecoveryAlgorithmType, SessionState};
|
||||
|
||||
// Constants for retry window management
|
||||
const RETRY_WINDOW_DAYS: i32 = 30;
|
||||
const INITIAL_RETRY_COUNT: i32 = 0;
|
||||
const RETRY_WINDOW_IN_HOUR: i32 = 720;
|
||||
|
||||
/// Payment processor token details including card information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
||||
@@ -39,7 +39,8 @@ pub struct PaymentProcessorTokenStatus {
|
||||
/// Error code associated with the token failure
|
||||
pub error_code: Option<String>,
|
||||
/// Daily retry count history for the last 30 days (date -> retry_count)
|
||||
pub daily_retry_history: HashMap<Date, i32>,
|
||||
#[serde(deserialize_with = "parse_datetime_key")]
|
||||
pub daily_retry_history: HashMap<PrimitiveDateTime, i32>,
|
||||
/// Scheduled time for the next retry attempt
|
||||
pub scheduled_at: Option<PrimitiveDateTime>,
|
||||
/// Indicates if the token is a hard decline (no retries allowed)
|
||||
@@ -50,6 +51,8 @@ pub struct PaymentProcessorTokenStatus {
|
||||
pub is_active: Option<bool>,
|
||||
/// Update history of the token
|
||||
pub account_update_history: Option<Vec<AccountUpdateHistoryRecord>>,
|
||||
/// Previous Decision threshold for selecting the best slot
|
||||
pub decision_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<&PaymentProcessorTokenDetails> for api_models::payments::AdditionalCardInfo {
|
||||
@@ -74,6 +77,34 @@ impl From<&PaymentProcessorTokenDetails> for api_models::payments::AdditionalCar
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_datetime_key<'de, D>(deserializer: D) -> Result<HashMap<PrimitiveDateTime, i32>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw: HashMap<String, i32> = HashMap::deserialize(deserializer)?;
|
||||
let mut parsed = HashMap::new();
|
||||
|
||||
// Full datetime
|
||||
let full_dt_format = time::macros::format_description!(
|
||||
"[year]-[month]-[day] [hour]:[minute]:[second].[subsecond]"
|
||||
);
|
||||
// Date only
|
||||
let date_only_format = time::macros::format_description!("[year]-[month]-[day]");
|
||||
|
||||
for (k, v) in raw {
|
||||
let dt = PrimitiveDateTime::parse(&k, &full_dt_format)
|
||||
.or_else(|_| {
|
||||
Date::parse(&k, &date_only_format)
|
||||
.map(|date| PrimitiveDateTime::new(date, Time::MIDNIGHT))
|
||||
})
|
||||
.map_err(|_| serde::de::Error::custom(format!("Invalid date key: {}", k)))?;
|
||||
|
||||
parsed.insert(dt, v);
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Token retry availability information with detailed wait times
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenRetryInfo {
|
||||
@@ -299,13 +330,17 @@ impl RedisTokenManager {
|
||||
|
||||
/// Find the most recent date from retry history
|
||||
pub fn find_nearest_date_from_current(
|
||||
retry_history: &HashMap<Date, i32>,
|
||||
) -> Option<(Date, i32)> {
|
||||
let today = OffsetDateTime::now_utc().date();
|
||||
retry_history: &HashMap<PrimitiveDateTime, i32>,
|
||||
) -> Option<(PrimitiveDateTime, i32)> {
|
||||
let now_utc = OffsetDateTime::now_utc();
|
||||
let reference_time = PrimitiveDateTime::new(
|
||||
now_utc.date(),
|
||||
Time::from_hms(now_utc.hour(), 0, 0).unwrap_or(Time::MIDNIGHT),
|
||||
);
|
||||
|
||||
retry_history
|
||||
.iter()
|
||||
.filter(|(date, _)| **date <= today) // Only past dates + today
|
||||
.filter(|(date, _)| **date <= reference_time) // Only past dates + today
|
||||
.max_by_key(|(date, _)| *date) // Get the most recent
|
||||
.map(|(date, retry_count)| (*date, *retry_count))
|
||||
}
|
||||
@@ -362,24 +397,15 @@ impl RedisTokenManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current date in `yyyy-mm-dd` format.
|
||||
pub fn get_current_date() -> String {
|
||||
let today = date_time::now().date();
|
||||
|
||||
let (year, month, day) = (today.year(), today.month(), today.day());
|
||||
|
||||
format!("{year:04}-{month:02}-{day:02}",)
|
||||
}
|
||||
|
||||
/// Normalize retry window to exactly `RETRY_WINDOW_DAYS` days (today to `RETRY_WINDOW_DAYS - 1` days ago).
|
||||
pub fn normalize_retry_window(
|
||||
payment_processor_token: &mut PaymentProcessorTokenStatus,
|
||||
today: Date,
|
||||
reference_time: PrimitiveDateTime,
|
||||
) {
|
||||
let mut normalized_retry_history: HashMap<Date, i32> = HashMap::new();
|
||||
let mut normalized_retry_history: HashMap<PrimitiveDateTime, i32> = HashMap::new();
|
||||
|
||||
for days_ago in 0..RETRY_WINDOW_DAYS {
|
||||
let date = today - Duration::days(days_ago.into());
|
||||
for hours_ago in 0..RETRY_WINDOW_IN_HOUR {
|
||||
let date = reference_time - Duration::hours(hours_ago.into());
|
||||
|
||||
payment_processor_token
|
||||
.daily_retry_history
|
||||
@@ -415,7 +441,6 @@ impl RedisTokenManager {
|
||||
let retry_info = Self::payment_processor_token_retry_info(
|
||||
state,
|
||||
payment_processor_token_status,
|
||||
today,
|
||||
card_network.clone(),
|
||||
);
|
||||
|
||||
@@ -449,13 +474,17 @@ impl RedisTokenManager {
|
||||
}
|
||||
|
||||
/// Sum retries over exactly the last 30 days
|
||||
fn calculate_total_30_day_retries(token: &PaymentProcessorTokenStatus, today: Date) -> i32 {
|
||||
(0..RETRY_WINDOW_DAYS)
|
||||
fn calculate_total_30_day_retries(
|
||||
token: &PaymentProcessorTokenStatus,
|
||||
reference_time: PrimitiveDateTime,
|
||||
) -> i32 {
|
||||
(0..RETRY_WINDOW_IN_HOUR)
|
||||
.map(|i| {
|
||||
let date = today - Duration::days(i.into());
|
||||
let target_hour = reference_time - Duration::hours(i.into());
|
||||
|
||||
token
|
||||
.daily_retry_history
|
||||
.get(&date)
|
||||
.get(&target_hour)
|
||||
.copied()
|
||||
.unwrap_or(INITIAL_RETRY_COUNT)
|
||||
})
|
||||
@@ -463,53 +492,76 @@ impl RedisTokenManager {
|
||||
}
|
||||
|
||||
/// Calculate wait hours
|
||||
fn calculate_wait_hours(target_date: Date, now: OffsetDateTime) -> i64 {
|
||||
let expiry_time = target_date.midnight().assume_utc();
|
||||
fn calculate_wait_hours(target_date: PrimitiveDateTime, now: OffsetDateTime) -> i64 {
|
||||
let expiry_time = target_date.assume_utc();
|
||||
(expiry_time - now).whole_hours().max(0)
|
||||
}
|
||||
|
||||
/// Calculate retry counts for exactly the last 30 days
|
||||
/// Calculate retry counts for exactly the last 30 days (hour-granular)
|
||||
pub fn payment_processor_token_retry_info(
|
||||
state: &SessionState,
|
||||
token: &PaymentProcessorTokenStatus,
|
||||
today: Date,
|
||||
network_type: Option<CardNetwork>,
|
||||
) -> TokenRetryInfo {
|
||||
let card_config = &state.conf.revenue_recovery.card_config;
|
||||
let card_network_config = card_config.get_network_config(network_type);
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let now_utc = OffsetDateTime::now_utc();
|
||||
let reference_time = PrimitiveDateTime::new(
|
||||
now_utc.date(),
|
||||
Time::from_hms(now_utc.hour(), 0, 0).unwrap_or(Time::MIDNIGHT),
|
||||
);
|
||||
|
||||
let total_30_day_retries = Self::calculate_total_30_day_retries(token, today);
|
||||
// Total retries for last 720 hours
|
||||
let total_30_day_retries = Self::calculate_total_30_day_retries(token, reference_time);
|
||||
|
||||
// Monthly wait-hour calculation ----
|
||||
let monthly_wait_hours =
|
||||
if total_30_day_retries >= card_network_config.max_retry_count_for_thirty_day {
|
||||
let mut accumulated_retries = 0;
|
||||
|
||||
// Iterate from most recent to oldest
|
||||
(0..RETRY_WINDOW_DAYS)
|
||||
.map(|days_ago| today - Duration::days(days_ago.into()))
|
||||
.find(|date| {
|
||||
let retries = token.daily_retry_history.get(date).copied().unwrap_or(0);
|
||||
(0..RETRY_WINDOW_IN_HOUR)
|
||||
.map(|i| reference_time - Duration::hours(i.into()))
|
||||
.find(|window_hour| {
|
||||
let retries = token
|
||||
.daily_retry_history
|
||||
.get(window_hour)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
accumulated_retries += retries;
|
||||
|
||||
accumulated_retries >= card_network_config.max_retry_count_for_thirty_day
|
||||
})
|
||||
.map(|breach_date| {
|
||||
Self::calculate_wait_hours(breach_date + Duration::days(31), now)
|
||||
.map(|breach_hour| {
|
||||
let allowed_at = breach_hour + Duration::days(31);
|
||||
Self::calculate_wait_hours(allowed_at, now_utc)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let today_retries = token
|
||||
.daily_retry_history
|
||||
.get(&today)
|
||||
.copied()
|
||||
.unwrap_or(INITIAL_RETRY_COUNT);
|
||||
// Today's retries (using hourly buckets) ----
|
||||
let today_date = reference_time.date();
|
||||
|
||||
let today_retries: i32 = (0..24)
|
||||
.map(|h| {
|
||||
let hour_bucket = PrimitiveDateTime::new(
|
||||
today_date,
|
||||
Time::from_hms(h, 0, 0).unwrap_or(Time::MIDNIGHT),
|
||||
);
|
||||
token
|
||||
.daily_retry_history
|
||||
.get(&hour_bucket)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum();
|
||||
|
||||
let daily_wait_hours = if today_retries >= card_network_config.max_retries_per_day {
|
||||
Self::calculate_wait_hours(today + Duration::days(1), now)
|
||||
let tomorrow_start =
|
||||
PrimitiveDateTime::new(today_date + Duration::days(1), Time::MIDNIGHT);
|
||||
Self::calculate_wait_hours(tomorrow_start, now_utc)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -542,12 +594,16 @@ impl RedisTokenManager {
|
||||
|
||||
let last_external_attempt_at = token_data.modified_at;
|
||||
|
||||
let today = OffsetDateTime::now_utc().date();
|
||||
let now_utc = OffsetDateTime::now_utc();
|
||||
let reference_time = PrimitiveDateTime::new(
|
||||
now_utc.date(),
|
||||
Time::from_hms(now_utc.hour(), 0, 0).unwrap_or(Time::MIDNIGHT),
|
||||
);
|
||||
|
||||
token_map
|
||||
.get_mut(&token_id)
|
||||
.map(|existing_token| {
|
||||
Self::normalize_retry_window(existing_token, today);
|
||||
Self::normalize_retry_window(existing_token, reference_time);
|
||||
|
||||
for (date, &value) in &token_data.daily_retry_history {
|
||||
existing_token
|
||||
@@ -611,7 +667,11 @@ impl RedisTokenManager {
|
||||
is_hard_decline: &Option<bool>,
|
||||
payment_processor_token_id: Option<&str>,
|
||||
) -> CustomResult<bool, errors::StorageError> {
|
||||
let today = OffsetDateTime::now_utc().date();
|
||||
let now_utc = OffsetDateTime::now_utc();
|
||||
let reference_time = PrimitiveDateTime::new(
|
||||
now_utc.date(),
|
||||
Time::from_hms(now_utc.hour(), 0, 0).unwrap_or(Time::MIDNIGHT),
|
||||
);
|
||||
let updated_token = match payment_processor_token_id {
|
||||
Some(token_id) => {
|
||||
Self::get_connector_customer_payment_processor_tokens(state, connector_customer_id)
|
||||
@@ -638,6 +698,7 @@ impl RedisTokenManager {
|
||||
)),
|
||||
is_active: status.is_active,
|
||||
account_update_history: status.account_update_history.clone(),
|
||||
decision_threshold: status.decision_threshold,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
@@ -645,17 +706,19 @@ impl RedisTokenManager {
|
||||
|
||||
match updated_token {
|
||||
Some(mut token) => {
|
||||
Self::normalize_retry_window(&mut token, today);
|
||||
Self::normalize_retry_window(&mut token, reference_time);
|
||||
|
||||
match token.error_code {
|
||||
None => token.daily_retry_history.clear(),
|
||||
Some(_) => {
|
||||
let current_count = token
|
||||
.daily_retry_history
|
||||
.get(&today)
|
||||
.get(&reference_time)
|
||||
.copied()
|
||||
.unwrap_or(INITIAL_RETRY_COUNT);
|
||||
token.daily_retry_history.insert(today, current_count + 1);
|
||||
token
|
||||
.daily_retry_history
|
||||
.insert(reference_time, current_count + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,6 +779,7 @@ impl RedisTokenManager {
|
||||
)),
|
||||
is_active: status.is_active,
|
||||
account_update_history: status.account_update_history.clone(),
|
||||
decision_threshold: status.decision_threshold,
|
||||
};
|
||||
updated_tokens_map.insert(token_id, updated_status);
|
||||
}
|
||||
@@ -742,6 +806,7 @@ impl RedisTokenManager {
|
||||
connector_customer_id: &str,
|
||||
payment_processor_token: &str,
|
||||
schedule_time: Option<PrimitiveDateTime>,
|
||||
decision_threshold: Option<f64>,
|
||||
) -> CustomResult<bool, errors::StorageError> {
|
||||
let updated_token =
|
||||
Self::get_connector_customer_payment_processor_tokens(state, connector_customer_id)
|
||||
@@ -766,6 +831,7 @@ impl RedisTokenManager {
|
||||
)),
|
||||
is_active: status.is_active,
|
||||
account_update_history: status.account_update_history.clone(),
|
||||
decision_threshold: decision_threshold.or(status.decision_threshold),
|
||||
});
|
||||
|
||||
match updated_token {
|
||||
@@ -918,6 +984,7 @@ impl RedisTokenManager {
|
||||
connector_customer_id,
|
||||
&t.payment_processor_token_details.payment_processor_token,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1307,6 +1374,7 @@ impl AccountUpdaterAction {
|
||||
updated_mandate_details,
|
||||
)),
|
||||
}]),
|
||||
decision_threshold: None,
|
||||
};
|
||||
|
||||
RedisTokenManager::upsert_payment_processor_token(state, customer_id, new_token)
|
||||
|
||||
@@ -271,13 +271,19 @@ pub(crate) async fn get_schedule_time_to_retry_mit_payments(
|
||||
scheduler_utils::get_time_from_delta(time_delta)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryDecision {
|
||||
pub retry_time: time::PrimitiveDateTime,
|
||||
pub decision_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
pub(crate) async fn get_schedule_time_for_smart_retry(
|
||||
state: &SessionState,
|
||||
payment_intent: &PaymentIntent,
|
||||
retry_after_time: Option<prost_types::Timestamp>,
|
||||
token_with_retry_info: &PaymentProcessorTokenWithRetryInfo,
|
||||
) -> Result<Option<time::PrimitiveDateTime>, errors::ProcessTrackerError> {
|
||||
) -> Result<Option<RetryDecision>, errors::ProcessTrackerError> {
|
||||
let card_config = &state.conf.revenue_recovery.card_config;
|
||||
|
||||
// Not populating it right now
|
||||
@@ -395,6 +401,14 @@ pub(crate) async fn get_schedule_time_for_smart_retry(
|
||||
),
|
||||
first_error_msg_time: None,
|
||||
wait_time: retry_after_time,
|
||||
payment_id: Some(payment_intent.get_id().get_string_repr().to_string()),
|
||||
hourly_retry_history: Some(
|
||||
token_with_retry_info
|
||||
.token_status
|
||||
.daily_retry_history
|
||||
.clone(),
|
||||
),
|
||||
previous_threshold: token_with_retry_info.token_status.decision_threshold,
|
||||
};
|
||||
|
||||
if let Some(mut client) = state.grpc_client.recovery_decider_client.clone() {
|
||||
@@ -408,7 +422,13 @@ pub(crate) async fn get_schedule_time_for_smart_retry(
|
||||
.and(grpc_response.retry_time)
|
||||
.and_then(|prost_ts| {
|
||||
match date_time::convert_from_prost_timestamp(&prost_ts) {
|
||||
Ok(pdt) => Some(pdt),
|
||||
Ok(pdt) => {
|
||||
let response = RetryDecision {
|
||||
retry_time: pdt,
|
||||
decision_threshold: grpc_response.decision_threshold,
|
||||
};
|
||||
Some(response)
|
||||
}
|
||||
Err(e) => {
|
||||
logger::error!(
|
||||
"Failed to convert retry_time from prost::Timestamp: {e:?}"
|
||||
@@ -458,13 +478,30 @@ async fn should_force_schedule_due_to_missed_slots(
|
||||
.max_retry_count_for_thirty_day;
|
||||
|
||||
// Calculate time difference since last retry and compare with threshold
|
||||
(time::OffsetDateTime::now_utc() - most_recent_date.midnight().assume_utc()).whole_hours()
|
||||
(time::OffsetDateTime::now_utc() - most_recent_date.assume_utc()).whole_hours()
|
||||
> threshold_hours.into()
|
||||
})
|
||||
// Default to false if no valid retry history found (either none exists or all have retry_count = 0)
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
pub fn convert_hourly_retry_history(
|
||||
input: Option<HashMap<time::PrimitiveDateTime, i32>>,
|
||||
) -> HashMap<String, i32> {
|
||||
let fmt = time::macros::format_description!(
|
||||
"[year]-[month]-[day] [hour]:[minute]:[second].[subsecond]"
|
||||
);
|
||||
|
||||
match input {
|
||||
Some(map) => map
|
||||
.into_iter()
|
||||
.map(|(dt, count)| (dt.format(&fmt).unwrap_or(dt.to_string()), count))
|
||||
.collect(),
|
||||
None => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
#[derive(Debug)]
|
||||
struct InternalDeciderRequest {
|
||||
@@ -497,6 +534,9 @@ struct InternalDeciderRequest {
|
||||
total_retry_count_within_network: Option<i64>,
|
||||
first_error_msg_time: Option<prost_types::Timestamp>,
|
||||
wait_time: Option<prost_types::Timestamp>,
|
||||
payment_id: Option<String>,
|
||||
hourly_retry_history: Option<HashMap<time::PrimitiveDateTime, i32>>,
|
||||
previous_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
@@ -532,6 +572,11 @@ impl From<InternalDeciderRequest> for external_grpc_client::DeciderRequest {
|
||||
total_retry_count_within_network: internal_request.total_retry_count_within_network,
|
||||
first_error_msg_time: internal_request.first_error_msg_time,
|
||||
wait_time: internal_request.wait_time,
|
||||
payment_id: internal_request.payment_id,
|
||||
hourly_retry_history: convert_hourly_retry_history(
|
||||
internal_request.hourly_retry_history,
|
||||
),
|
||||
previous_threshold: internal_request.previous_threshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,7 +585,7 @@ impl From<InternalDeciderRequest> for external_grpc_client::DeciderRequest {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScheduledToken {
|
||||
pub token_details: PaymentProcessorTokenDetails,
|
||||
pub schedule_time: time::PrimitiveDateTime,
|
||||
pub retry_decision: RetryDecision,
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
@@ -831,7 +876,7 @@ pub async fn calculate_smart_retry_time(
|
||||
state: &SessionState,
|
||||
payment_intent: &PaymentIntent,
|
||||
token_with_retry_info: &PaymentProcessorTokenWithRetryInfo,
|
||||
) -> Result<(Option<time::PrimitiveDateTime>, bool), errors::ProcessTrackerError> {
|
||||
) -> Result<(Option<RetryDecision>, bool), errors::ProcessTrackerError> {
|
||||
let wait_hours = token_with_retry_info.retry_wait_time_hours;
|
||||
let current_time = time::OffsetDateTime::now_utc();
|
||||
let future_time = current_time + time::Duration::hours(wait_hours);
|
||||
@@ -875,16 +920,20 @@ pub async fn calculate_smart_retry_time(
|
||||
scheduled_time
|
||||
);
|
||||
return Ok((
|
||||
Some(time::PrimitiveDateTime::new(
|
||||
scheduled_time.date(),
|
||||
scheduled_time.time(),
|
||||
)),
|
||||
true,
|
||||
)); // force_scheduled = true
|
||||
Some(RetryDecision {
|
||||
retry_time: time::PrimitiveDateTime::new(
|
||||
scheduled_time.date(),
|
||||
scheduled_time.time(),
|
||||
),
|
||||
// Not populating decision_threshold in forced schedule as there is no decider call
|
||||
decision_threshold: None,
|
||||
}),
|
||||
true, // force_scheduled
|
||||
));
|
||||
}
|
||||
|
||||
// Normal smart retry path
|
||||
let schedule_time = get_schedule_time_for_smart_retry(
|
||||
let retry_decision = get_schedule_time_for_smart_retry(
|
||||
state,
|
||||
payment_intent,
|
||||
future_timestamp,
|
||||
@@ -892,7 +941,7 @@ pub async fn calculate_smart_retry_time(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((schedule_time, false)) // force_scheduled = false
|
||||
Ok((retry_decision, false)) // force_scheduled = false
|
||||
}
|
||||
|
||||
#[cfg(feature = "v2")]
|
||||
@@ -918,13 +967,13 @@ async fn process_token_for_retry(
|
||||
})
|
||||
}
|
||||
false => {
|
||||
let (schedule_time, force_scheduled) =
|
||||
let (retry_decision, force_scheduled) =
|
||||
calculate_smart_retry_time(state, payment_intent, token_with_retry_info).await?;
|
||||
|
||||
Ok(TokenProcessResult {
|
||||
scheduled_token: schedule_time.map(|schedule_time| ScheduledToken {
|
||||
scheduled_token: retry_decision.map(|retry_decision| ScheduledToken {
|
||||
token_details: token_status.payment_processor_token_details.clone(),
|
||||
schedule_time,
|
||||
retry_decision,
|
||||
}),
|
||||
force_scheduled,
|
||||
})
|
||||
@@ -960,7 +1009,11 @@ pub async fn call_decider_for_payment_processor_tokens_select_closest_time(
|
||||
|
||||
tokens_with_schedule_time = vec![ScheduledToken {
|
||||
token_details: token_details.clone(),
|
||||
schedule_time,
|
||||
retry_decision: RetryDecision {
|
||||
retry_time: schedule_time,
|
||||
// Not populating decision_threshold for successful token as there is no decider call
|
||||
decision_threshold: None,
|
||||
},
|
||||
}];
|
||||
|
||||
tracing::debug!(
|
||||
@@ -995,7 +1048,7 @@ pub async fn call_decider_for_payment_processor_tokens_select_closest_time(
|
||||
|
||||
let best_token = tokens_with_schedule_time
|
||||
.iter()
|
||||
.min_by_key(|token| token.schedule_time)
|
||||
.min_by_key(|token| token.retry_decision.retry_time)
|
||||
.cloned();
|
||||
|
||||
let mut payment_processor_token_response;
|
||||
@@ -1040,13 +1093,14 @@ pub async fn call_decider_for_payment_processor_tokens_select_closest_time(
|
||||
state,
|
||||
connector_customer_id,
|
||||
&token.token_details.payment_processor_token,
|
||||
Some(token.schedule_time),
|
||||
Some(token.retry_decision.retry_time),
|
||||
token.retry_decision.decision_threshold,
|
||||
)
|
||||
.await
|
||||
.change_context(errors::ProcessTrackerError::EApiErrorResponse)?;
|
||||
|
||||
payment_processor_token_response = PaymentProcessorTokenResponse::ScheduledTime {
|
||||
scheduled_time: token.schedule_time,
|
||||
scheduled_time: token.retry_decision.retry_time,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,13 @@ message DeciderRequest {
|
||||
optional int64 total_retry_count_within_network = 27;
|
||||
optional google.protobuf.Timestamp first_error_msg_time = 28;
|
||||
optional google.protobuf.Timestamp wait_time = 29;
|
||||
optional string payment_id= 30;
|
||||
map<string, int32> hourly_retry_history = 31;
|
||||
optional double previous_threshold= 32;
|
||||
}
|
||||
|
||||
message DeciderResponse {
|
||||
bool retry_flag = 1;
|
||||
google.protobuf.Timestamp retry_time = 2;
|
||||
optional double decision_threshold= 3;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user