use super::tracker::{BudgetTracker, SpendResult};
use super::types::{Budget, BudgetCheckResult, BudgetConfig, BudgetScope, BudgetStatus};
use crate::utils::error::gateway_error::{GatewayError, Result};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
#[derive(Clone)]
pub struct BudgetManager {
tracker: Arc<BudgetTracker>,
config: Arc<RwLock<BudgetManagerConfig>>,
}
#[derive(Debug, Clone)]
pub struct BudgetManagerConfig {
pub enabled: bool,
pub default_soft_limit_percentage: f64,
pub block_on_exceeded: bool,
pub auto_reset_enabled: bool,
pub reset_check_interval_secs: u64,
}
impl Default for BudgetManagerConfig {
fn default() -> Self {
Self {
enabled: true,
default_soft_limit_percentage: 0.8,
block_on_exceeded: true,
auto_reset_enabled: true,
reset_check_interval_secs: 60,
}
}
}
impl Default for BudgetManager {
fn default() -> Self {
Self::new()
}
}
impl BudgetManager {
pub fn new() -> Self {
Self {
tracker: Arc::new(BudgetTracker::new()),
config: Arc::new(RwLock::new(BudgetManagerConfig::default())),
}
}
pub fn with_config(config: BudgetManagerConfig) -> Self {
Self {
tracker: Arc::new(BudgetTracker::new()),
config: Arc::new(RwLock::new(config)),
}
}
pub fn with_tracker(tracker: BudgetTracker) -> Self {
Self {
tracker: Arc::new(tracker),
config: Arc::new(RwLock::new(BudgetManagerConfig::default())),
}
}
pub fn tracker(&self) -> &BudgetTracker {
&self.tracker
}
pub async fn create_budget(&self, scope: BudgetScope, config: BudgetConfig) -> Result<Budget> {
if !config.max_budget.is_finite() || config.max_budget <= 0.0 {
return Err(GatewayError::Validation(
"max_budget must be finite and greater than 0".to_string(),
));
}
if let Some(soft_limit) = config.soft_limit
&& (!soft_limit.is_finite() || soft_limit < 0.0)
{
return Err(GatewayError::Validation(
"soft_limit must be finite and non-negative".to_string(),
));
}
if config.name.trim().is_empty() {
return Err(GatewayError::Validation(
"Budget name cannot be empty".to_string(),
));
}
let id = uuid::Uuid::new_v4().to_string();
let manager_config = self.config.read().await;
let soft_limit = config
.soft_limit
.unwrap_or(config.max_budget * manager_config.default_soft_limit_percentage);
let mut budget = Budget::new(&id, &config.name, scope.clone(), config.max_budget);
budget.soft_limit = soft_limit;
if let Some(period) = config.reset_period {
budget.reset_period = period;
}
if let Some(currency) = config.currency {
budget.currency = currency;
}
if let Some(enabled) = config.enabled {
budget.enabled = enabled;
}
if let Some(metadata) = config.metadata {
budget.metadata = metadata;
}
info!(
"Creating budget '{}' for scope {} with max ${:.2}",
budget.name, scope, budget.max_budget
);
if !self.tracker.try_register_budget(budget.clone()) {
return Err(GatewayError::Conflict(format!(
"Budget already exists for scope: {}",
scope
)));
}
Ok(budget)
}
pub async fn update_budget(&self, scope: &BudgetScope, config: BudgetConfig) -> Result<Budget> {
if !self.tracker.has_budget(scope) {
return Err(GatewayError::NotFound(format!(
"Budget not found for scope: {}",
scope
)));
}
if !config.max_budget.is_finite() || config.max_budget <= 0.0 {
return Err(GatewayError::Validation(
"max_budget must be finite and greater than 0".to_string(),
));
}
if let Some(soft_limit) = config.soft_limit
&& (!soft_limit.is_finite() || soft_limit < 0.0)
{
return Err(GatewayError::Validation(
"soft_limit must be finite and non-negative".to_string(),
));
}
let manager_config = self.config.read().await;
let updated = self.tracker.update_budget(scope, |budget| {
budget.name = config.name.clone();
budget.max_budget = config.max_budget;
budget.soft_limit = config
.soft_limit
.unwrap_or(config.max_budget * manager_config.default_soft_limit_percentage);
if let Some(period) = config.reset_period {
budget.reset_period = period;
}
if let Some(currency) = config.currency {
budget.currency = currency;
}
if let Some(enabled) = config.enabled {
budget.enabled = enabled;
}
if let Some(metadata) = config.metadata.clone() {
budget.metadata = metadata;
}
debug!(
"Updated budget '{}' for scope {} with max ${:.2}",
budget.name, scope, budget.max_budget
);
});
if updated {
self.tracker.get_budget(scope).ok_or_else(|| {
GatewayError::Internal("Failed to retrieve updated budget".to_string())
})
} else {
Err(GatewayError::Internal(
"Failed to update budget".to_string(),
))
}
}
pub async fn delete_budget(&self, scope: &BudgetScope) -> Result<()> {
if !self.tracker.has_budget(scope) {
return Err(GatewayError::NotFound(format!(
"Budget not found for scope: {}",
scope
)));
}
info!("Deleting budget for scope: {}", scope);
self.tracker.unregister_budget(scope);
Ok(())
}
pub fn get_budget(&self, scope: &BudgetScope) -> Result<Budget> {
self.tracker
.get_budget(scope)
.ok_or_else(|| GatewayError::NotFound(format!("Budget not found for scope: {}", scope)))
}
pub fn get_budget_by_id(&self, id: &str) -> Option<Budget> {
self.tracker
.get_all_budgets()
.into_iter()
.find(|b| b.id == id)
}
pub fn list_budgets(&self) -> Vec<Budget> {
self.tracker.get_all_budgets()
}
pub fn list_budgets_filtered(
&self,
scope_type: Option<&str>,
status: Option<BudgetStatus>,
) -> Vec<Budget> {
let mut budgets = match scope_type {
Some(t) => self.tracker.get_budgets_by_type(t),
None => self.tracker.get_all_budgets(),
};
if let Some(status_filter) = status {
budgets.retain(|b| b.status() == status_filter);
}
budgets
}
pub async fn record_spend(&self, scope: &BudgetScope, amount: f64) -> Option<SpendResult> {
if amount <= 0.0 {
warn!("Attempted to record non-positive spend: {}", amount);
return None;
}
self.tracker.record_spend(scope, amount)
}
pub async fn check_spend(&self, scope: &BudgetScope, amount: f64) -> BudgetCheckResult {
let config = self.config.read().await;
if !config.enabled {
return BudgetCheckResult::no_budget();
}
let result = self.tracker.check_spend(scope, amount);
if !config.block_on_exceeded && !result.allowed {
return BudgetCheckResult {
allowed: true,
..result
};
}
result
}
pub fn check_budget(&self, scope: &BudgetScope) -> BudgetCheckResult {
self.tracker.check_budget(scope)
}
pub fn get_remaining(&self, scope: &BudgetScope) -> f64 {
self.tracker.get_remaining(scope)
}
pub fn get_current_spend(&self, scope: &BudgetScope) -> f64 {
self.tracker.get_current_spend(scope)
}
pub async fn reset_budget(&self, scope: &BudgetScope) -> Result<()> {
if !self.tracker.has_budget(scope) {
return Err(GatewayError::NotFound(format!(
"Budget not found for scope: {}",
scope
)));
}
self.tracker.reset_budget(scope);
info!("Reset budget for scope: {}", scope);
Ok(())
}
pub fn run_periodic_reset(&self) -> Vec<String> {
self.tracker.reset_budgets()
}
pub fn start_reset_task(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let interval = {
let config = self.config.read().await;
if !config.auto_reset_enabled {
tokio::time::Duration::from_secs(60)
} else {
tokio::time::Duration::from_secs(config.reset_check_interval_secs)
}
};
tokio::time::sleep(interval).await;
let config = self.config.read().await;
if config.auto_reset_enabled {
drop(config);
let reset_ids = self.run_periodic_reset();
if !reset_ids.is_empty() {
info!("Periodic reset: {} budgets reset", reset_ids.len());
}
}
}
})
}
pub fn get_warning_budgets(&self) -> Vec<Budget> {
self.tracker.get_warning_budgets()
}
pub fn get_exceeded_budgets(&self) -> Vec<Budget> {
self.tracker.get_exceeded_budgets()
}
pub fn budget_count(&self) -> usize {
self.tracker.budget_count()
}
pub async fn update_config(&self, new_config: BudgetManagerConfig) {
let mut config = self.config.write().await;
*config = new_config;
}
pub async fn get_config(&self) -> BudgetManagerConfig {
self.config.read().await.clone()
}
pub async fn is_enabled(&self) -> bool {
self.config.read().await.enabled
}
pub async fn set_enabled(&self, enabled: bool) {
let mut config = self.config.write().await;
config.enabled = enabled;
}
pub fn get_summary(&self) -> BudgetSummary {
let budgets = self.tracker.get_all_budgets();
let total_budgets = budgets.len();
let mut total_allocated = 0.0;
let mut total_spent = 0.0;
let mut ok_count = 0;
let mut warning_count = 0;
let mut exceeded_count = 0;
for budget in &budgets {
total_allocated += budget.max_budget;
total_spent += budget.current_spend;
match budget.status() {
BudgetStatus::Ok => ok_count += 1,
BudgetStatus::Warning => warning_count += 1,
BudgetStatus::Exceeded => exceeded_count += 1,
}
}
BudgetSummary {
total_budgets,
total_allocated,
total_spent,
total_remaining: (total_allocated - total_spent).max(0.0),
ok_count,
warning_count,
exceeded_count,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BudgetSummary {
pub total_budgets: usize,
pub total_allocated: f64,
pub total_spent: f64,
pub total_remaining: f64,
pub ok_count: usize,
pub warning_count: usize,
pub exceeded_count: usize,
}