Skip to main content

ccboard_core/
quota.rs

1//! Quota tracking and budget alerts
2//!
3//! MVP approach: Uses total cost from model_usage with prorata for month-to-date.
4//! Simple projection based on daily average (no forecasting for v0.8.0 MVP).
5
6use crate::models::{config::BudgetConfig, stats::StatsCache};
7use chrono::{Datelike, Local};
8
9/// Alert level based on budget usage
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AlertLevel {
12    /// Usage < warning threshold (green)
13    Safe,
14    /// Usage >= warning threshold (yellow)
15    Warning,
16    /// Usage >= critical threshold (red)
17    Critical,
18    /// Usage >= 100% (magenta)
19    Exceeded,
20}
21
22/// Quota status with current usage and projections
23#[derive(Debug, Clone)]
24pub struct QuotaStatus {
25    /// Current month-to-date cost in USD
26    pub current_cost: f64,
27    /// Configured monthly budget limit (None = unlimited)
28    pub budget_limit: Option<f64>,
29    /// Usage percentage (0.0-999.9, clamped for display)
30    pub usage_pct: f64,
31    /// Projected cost at end of month (simple daily average)
32    pub projected_monthly_cost: f64,
33    /// Projected overage (None if under budget or no limit)
34    pub projected_overage: Option<f64>,
35    /// Current alert level
36    pub alert_level: AlertLevel,
37}
38
39/// Calculate quota status from stats and budget config
40///
41/// MVP approach:
42/// 1. Calculate MTD cost from total model_usage.cost_usd + prorata for current month
43/// 2. Project monthly cost as simple daily average * 30 days
44pub fn calculate_quota_status(stats: &StatsCache, budget: &BudgetConfig) -> QuotaStatus {
45    // 1. Calculate month-to-date cost (simple prorata from total)
46    let current_cost = calculate_month_to_date_cost(stats);
47
48    // 2. Calculate usage percentage
49    let usage_pct = if let Some(limit) = budget.monthly_limit {
50        (current_cost / limit * 100.0).min(999.9)
51    } else {
52        0.0
53    };
54
55    // 3. Determine alert level
56    let alert_level = determine_alert_level(usage_pct, budget);
57
58    // 4. Project monthly cost (simple: daily avg * 30)
59    let projected_monthly_cost = project_monthly_cost(stats, current_cost);
60
61    // 5. Calculate projected overage if limit exists
62    let projected_overage = if let Some(limit) = budget.monthly_limit {
63        if projected_monthly_cost > limit {
64            Some(projected_monthly_cost - limit)
65        } else {
66            None
67        }
68    } else {
69        None
70    };
71
72    QuotaStatus {
73        current_cost,
74        budget_limit: budget.monthly_limit,
75        usage_pct,
76        projected_monthly_cost,
77        projected_overage,
78        alert_level,
79    }
80}
81
82/// Calculate month-to-date cost using token-based prorata
83///
84/// Uses daily_model_tokens to compute the proportion of tokens in current month vs total.
85fn calculate_month_to_date_cost(stats: &StatsCache) -> f64 {
86    // Total cost across all models
87    let total_cost: f64 = stats.model_usage.values().map(|m| m.cost_usd).sum();
88
89    if total_cost == 0.0 {
90        return 0.0;
91    }
92
93    // Get current month prefix (e.g., "2026-02")
94    let now = Local::now();
95    let month_prefix = format!("{}-{:02}", now.year(), now.month());
96
97    // Calculate total tokens in current month from daily_model_tokens
98    let mtd_tokens: u64 = stats
99        .daily_model_tokens
100        .iter()
101        .filter(|d| d.date.starts_with(&month_prefix))
102        .flat_map(|d| d.tokens_by_model.values())
103        .sum();
104
105    // Calculate total tokens across all models
106    let total_tokens: u64 = stats
107        .model_usage
108        .values()
109        .map(|m| m.input_tokens + m.output_tokens)
110        .sum();
111
112    if total_tokens == 0 {
113        return 0.0;
114    }
115
116    // MTD cost = total_cost * (mtd_tokens / total_tokens)
117    total_cost * (mtd_tokens as f64 / total_tokens as f64)
118}
119
120/// Project monthly cost using simple daily average
121///
122/// Calculates: (MTD cost / days in month so far) * 30
123fn project_monthly_cost(_stats: &StatsCache, mtd_cost: f64) -> f64 {
124    let now = Local::now();
125    let current_day = now.day() as f64;
126
127    if current_day < 1.0 {
128        return mtd_cost * 30.0; // Fallback
129    }
130
131    // Daily average * 30 days
132    let daily_avg = mtd_cost / current_day;
133    daily_avg * 30.0
134}
135
136/// Determine alert level from usage percentage and thresholds
137fn determine_alert_level(usage_pct: f64, budget: &BudgetConfig) -> AlertLevel {
138    if usage_pct >= 100.0 {
139        AlertLevel::Exceeded
140    } else if usage_pct >= budget.critical_threshold {
141        AlertLevel::Critical
142    } else if usage_pct >= budget.warning_threshold {
143        AlertLevel::Warning
144    } else {
145        AlertLevel::Safe
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::models::stats::ModelUsage;
153    use std::collections::HashMap;
154
155    /// Mock stats with specified MTD ratio
156    ///
157    /// `mtd_ratio`: proportion of tokens in current month (0.0-1.0)
158    /// Example: mtd_ratio=0.2 means 20% of tokens are in current month
159    fn mock_stats_with_mtd_ratio(total_cost: f64, mtd_ratio: f64, first_date: &str) -> StatsCache {
160        use crate::models::stats::DailyModelTokens;
161
162        let total_tokens = 15000u64;
163        let mtd_tokens = (total_tokens as f64 * mtd_ratio) as u64;
164
165        let mut model_usage = HashMap::new();
166        model_usage.insert(
167            "claude-sonnet-4".to_string(),
168            ModelUsage {
169                input_tokens: total_tokens * 2 / 3,
170                output_tokens: total_tokens / 3,
171                cost_usd: total_cost,
172                ..Default::default()
173            },
174        );
175
176        // Mock daily_model_tokens for current month
177        let now = Local::now();
178        let current_day = now.day();
179        let mut daily_model_tokens = Vec::new();
180
181        // Add token data for each day of current month so far
182        if mtd_tokens > 0 {
183            for day in 1..=current_day {
184                let mut tokens_by_model = HashMap::new();
185                tokens_by_model.insert(
186                    "claude-sonnet-4".to_string(),
187                    mtd_tokens / current_day as u64,
188                );
189
190                daily_model_tokens.push(DailyModelTokens {
191                    date: format!("{}-{:02}-{:02}", now.year(), now.month(), day),
192                    tokens_by_model,
193                });
194            }
195        }
196
197        StatsCache {
198            model_usage,
199            daily_model_tokens,
200            first_session_date: Some(first_date.to_string()),
201            ..Default::default()
202        }
203    }
204
205    // Convenience wrapper for backward compatibility
206    fn mock_stats_with_total_cost(total_cost: f64, first_date: &str) -> StatsCache {
207        mock_stats_with_mtd_ratio(total_cost, 1.0, first_date)
208    }
209
210    #[test]
211    fn test_quota_status_safe() {
212        // $100 total cost, 20% in current month → ~$20 MTD
213        // With $50 limit → 40% usage → Safe
214        let stats = mock_stats_with_mtd_ratio(100.0, 0.2, "2024-01-01");
215        let budget = BudgetConfig {
216            monthly_limit: Some(50.0),
217            warning_threshold: 75.0,
218            critical_threshold: 90.0,
219        };
220        let status = calculate_quota_status(&stats, &budget);
221
222        assert_eq!(status.alert_level, AlertLevel::Safe);
223        assert!(status.usage_pct < 75.0);
224    }
225
226    #[test]
227    fn test_quota_status_warning() {
228        // High total cost → high MTD
229        let stats = mock_stats_with_total_cost(500.0, "2024-01-01");
230        let budget = BudgetConfig {
231            monthly_limit: Some(100.0),
232            warning_threshold: 75.0,
233            critical_threshold: 90.0,
234        };
235        let status = calculate_quota_status(&stats, &budget);
236
237        // Should be warning or critical or exceeded
238        assert!(
239            status.alert_level == AlertLevel::Warning
240                || status.alert_level == AlertLevel::Critical
241                || status.alert_level == AlertLevel::Exceeded
242        );
243    }
244
245    #[test]
246    fn test_quota_no_limit() {
247        let stats = mock_stats_with_total_cost(1000.0, "2024-01-01");
248        let budget = BudgetConfig {
249            monthly_limit: None,
250            warning_threshold: 75.0,
251            critical_threshold: 90.0,
252        };
253        let status = calculate_quota_status(&stats, &budget);
254
255        assert_eq!(status.alert_level, AlertLevel::Safe);
256        assert_eq!(status.usage_pct, 0.0);
257        assert!(status.projected_overage.is_none());
258    }
259
260    #[test]
261    fn test_determine_alert_level() {
262        let budget = BudgetConfig {
263            monthly_limit: Some(100.0),
264            warning_threshold: 75.0,
265            critical_threshold: 90.0,
266        };
267
268        assert_eq!(determine_alert_level(50.0, &budget), AlertLevel::Safe);
269        assert_eq!(determine_alert_level(75.0, &budget), AlertLevel::Warning);
270        assert_eq!(determine_alert_level(90.0, &budget), AlertLevel::Critical);
271        assert_eq!(determine_alert_level(100.0, &budget), AlertLevel::Exceeded);
272        assert_eq!(determine_alert_level(120.0, &budget), AlertLevel::Exceeded);
273    }
274}