Skip to main content

ccboard_types/models/
billing_block.rs

1use chrono::{DateTime, Timelike, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Represents a 5-hour billing block as used by Claude Code pricing.
6///
7/// Claude Code charges based on 5-hour blocks in UTC time:
8/// - Block 1: 00:00-04:59 UTC
9/// - Block 2: 05:00-09:59 UTC
10/// - Block 3: 10:00-14:59 UTC
11/// - Block 4: 15:00-19:59 UTC
12/// - Block 5: 20:00-23:59 UTC
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct BillingBlock {
15    /// Date of the block (YYYY-MM-DD)
16    pub date: chrono::NaiveDate,
17    /// Starting hour of the 5-hour block (0, 5, 10, 15, 20)
18    pub block_hour: u8,
19}
20
21impl BillingBlock {
22    /// Create a BillingBlock from a timestamp
23    ///
24    /// # Examples
25    ///
26    /// ```
27    /// use chrono::{DateTime, Utc, TimeZone};
28    /// use ccboard_types::models::BillingBlock;
29    ///
30    /// let timestamp = Utc.with_ymd_and_hms(2026, 2, 2, 14, 30, 0).unwrap();
31    /// let block = BillingBlock::from_timestamp(&timestamp);
32    ///
33    /// assert_eq!(block.block_hour, 10); // 14:30 falls in 10:00-14:59 block
34    /// ```
35    pub fn from_timestamp(timestamp: &DateTime<Utc>) -> Self {
36        let date = timestamp.date_naive();
37        let hour = timestamp.hour() as u8;
38
39        // Normalize to 5-hour block: block_hour = (hour / 5) * 5
40        // 0-4 → 0, 5-9 → 5, 10-14 → 10, 15-19 → 15, 20-23 → 20
41        let block_hour = (hour / 5) * 5;
42
43        BillingBlock { date, block_hour }
44    }
45
46    /// Get the block label (e.g., "00:00-04:59", "05:00-09:59")
47    pub fn label(&self) -> String {
48        let end_hour = if self.block_hour == 20 {
49            23
50        } else {
51            self.block_hour + 4
52        };
53        format!("{:02}:00-{:02}:59", self.block_hour, end_hour)
54    }
55
56    /// Get the block number (1-5)
57    pub fn block_number(&self) -> u8 {
58        (self.block_hour / 5) + 1
59    }
60}
61
62/// Usage statistics for a billing block
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct BillingBlockUsage {
65    /// Input tokens
66    pub input_tokens: u64,
67    /// Output tokens
68    pub output_tokens: u64,
69    /// Cache creation tokens
70    pub cache_creation_tokens: u64,
71    /// Cache read tokens
72    pub cache_read_tokens: u64,
73    /// Total cost in USD
74    pub total_cost: f64,
75    /// Number of sessions in this block
76    pub session_count: usize,
77}
78
79impl BillingBlockUsage {
80    /// Total tokens (input + output + cache creation + cache read)
81    ///
82    /// IMPORTANT: This must match ccusage behavior which includes ALL token types.
83    /// Previously missed cache_read_tokens, causing discrepancies with ccusage totals.
84    pub fn total_tokens(&self) -> u64 {
85        self.input_tokens + self.output_tokens + self.cache_creation_tokens + self.cache_read_tokens
86    }
87
88    /// Add usage from another block
89    pub fn add(&mut self, other: &BillingBlockUsage) {
90        self.input_tokens += other.input_tokens;
91        self.output_tokens += other.output_tokens;
92        self.cache_creation_tokens += other.cache_creation_tokens;
93        self.cache_read_tokens += other.cache_read_tokens;
94        self.total_cost += other.total_cost;
95        self.session_count += other.session_count;
96    }
97}
98
99/// Manager for billing block tracking
100#[derive(Debug, Default)]
101pub struct BillingBlockManager {
102    /// Map of (date, block_hour) to usage
103    blocks: HashMap<BillingBlock, BillingBlockUsage>,
104}
105
106impl BillingBlockManager {
107    pub fn new() -> Self {
108        Self {
109            blocks: HashMap::new(),
110        }
111    }
112
113    /// Add usage to a billing block
114    pub fn add_usage(
115        &mut self,
116        timestamp: &DateTime<Utc>,
117        input_tokens: u64,
118        output_tokens: u64,
119        cache_creation_tokens: u64,
120        cache_read_tokens: u64,
121        cost: f64,
122    ) {
123        let block = BillingBlock::from_timestamp(timestamp);
124        let usage = self.blocks.entry(block).or_default();
125
126        usage.input_tokens += input_tokens;
127        usage.output_tokens += output_tokens;
128        usage.cache_creation_tokens += cache_creation_tokens;
129        usage.cache_read_tokens += cache_read_tokens;
130        usage.total_cost += cost;
131        usage.session_count += 1;
132    }
133
134    /// Get usage for a specific billing block
135    pub fn get_usage(&self, block: &BillingBlock) -> Option<&BillingBlockUsage> {
136        self.blocks.get(block)
137    }
138
139    /// Get all blocks sorted by date and block_hour
140    pub fn get_all_blocks(&self) -> Vec<(BillingBlock, BillingBlockUsage)> {
141        let mut blocks: Vec<_> = self
142            .blocks
143            .iter()
144            .map(|(block, usage)| (*block, usage.clone()))
145            .collect();
146
147        blocks.sort_by(|a, b| {
148            a.0.date
149                .cmp(&b.0.date)
150                .then_with(|| a.0.block_hour.cmp(&b.0.block_hour))
151        });
152
153        blocks
154    }
155
156    /// Get blocks for a specific date
157    pub fn get_blocks_for_date(
158        &self,
159        date: chrono::NaiveDate,
160    ) -> Vec<(BillingBlock, BillingBlockUsage)> {
161        let mut blocks: Vec<_> = self
162            .blocks
163            .iter()
164            .filter(|(block, _)| block.date == date)
165            .map(|(block, usage)| (*block, usage.clone()))
166            .collect();
167
168        blocks.sort_by_key(|(block, _)| block.block_hour);
169        blocks
170    }
171
172    /// Get color coding for a block based on cost thresholds
173    ///
174    /// - Green: < $2.5
175    /// - Yellow: < $5.0
176    /// - Red: >= $5.0
177    pub fn get_color_for_cost(cost: f64) -> &'static str {
178        if cost < 2.5 {
179            "green"
180        } else if cost < 5.0 {
181            "yellow"
182        } else {
183            "red"
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use chrono::TimeZone;
192
193    #[test]
194    fn test_billing_block_normalization() {
195        // Block 1: 00:00-04:59
196        let ts = Utc.with_ymd_and_hms(2026, 2, 2, 2, 30, 0).unwrap();
197        let block = BillingBlock::from_timestamp(&ts);
198        assert_eq!(block.block_hour, 0);
199        assert_eq!(block.label(), "00:00-04:59");
200        assert_eq!(block.block_number(), 1);
201
202        // Block 2: 05:00-09:59
203        let ts = Utc.with_ymd_and_hms(2026, 2, 2, 7, 15, 0).unwrap();
204        let block = BillingBlock::from_timestamp(&ts);
205        assert_eq!(block.block_hour, 5);
206        assert_eq!(block.label(), "05:00-09:59");
207        assert_eq!(block.block_number(), 2);
208
209        // Block 3: 10:00-14:59
210        let ts = Utc.with_ymd_and_hms(2026, 2, 2, 14, 59, 0).unwrap();
211        let block = BillingBlock::from_timestamp(&ts);
212        assert_eq!(block.block_hour, 10);
213        assert_eq!(block.label(), "10:00-14:59");
214        assert_eq!(block.block_number(), 3);
215
216        // Block 4: 15:00-19:59
217        let ts = Utc.with_ymd_and_hms(2026, 2, 2, 18, 0, 0).unwrap();
218        let block = BillingBlock::from_timestamp(&ts);
219        assert_eq!(block.block_hour, 15);
220        assert_eq!(block.label(), "15:00-19:59");
221        assert_eq!(block.block_number(), 4);
222
223        // Block 5: 20:00-23:59
224        let ts = Utc.with_ymd_and_hms(2026, 2, 2, 23, 59, 59).unwrap();
225        let block = BillingBlock::from_timestamp(&ts);
226        assert_eq!(block.block_hour, 20);
227        assert_eq!(block.label(), "20:00-23:59");
228        assert_eq!(block.block_number(), 5);
229    }
230
231    #[test]
232    fn test_billing_block_manager() {
233        let mut manager = BillingBlockManager::new();
234
235        // Add usage to block 1 (00:00-04:59)
236        let ts1 = Utc.with_ymd_and_hms(2026, 2, 2, 2, 0, 0).unwrap();
237        manager.add_usage(&ts1, 1000, 500, 100, 50, 0.5);
238
239        // Add more usage to same block
240        manager.add_usage(&ts1, 500, 250, 50, 25, 0.25);
241
242        // Add usage to block 2 (05:00-09:59)
243        let ts2 = Utc.with_ymd_and_hms(2026, 2, 2, 7, 0, 0).unwrap();
244        manager.add_usage(&ts2, 2000, 1000, 200, 100, 1.0);
245
246        // Check block 1 totals
247        let block1 = BillingBlock::from_timestamp(&ts1);
248        let usage1 = manager.get_usage(&block1).unwrap();
249        assert_eq!(usage1.input_tokens, 1500);
250        assert_eq!(usage1.output_tokens, 750);
251        assert_eq!(usage1.cache_creation_tokens, 150);
252        assert_eq!(usage1.cache_read_tokens, 75);
253        assert_eq!(usage1.total_cost, 0.75);
254        assert_eq!(usage1.session_count, 2);
255
256        // Check block 2 totals
257        let block2 = BillingBlock::from_timestamp(&ts2);
258        let usage2 = manager.get_usage(&block2).unwrap();
259        assert_eq!(usage2.input_tokens, 2000);
260        assert_eq!(usage2.total_cost, 1.0);
261        assert_eq!(usage2.session_count, 1);
262
263        // Check all blocks sorted
264        let all_blocks = manager.get_all_blocks();
265        assert_eq!(all_blocks.len(), 2);
266        assert_eq!(all_blocks[0].0.block_hour, 0); // Block 1 first
267        assert_eq!(all_blocks[1].0.block_hour, 5); // Block 2 second
268    }
269
270    #[test]
271    fn test_color_coding() {
272        assert_eq!(BillingBlockManager::get_color_for_cost(1.0), "green");
273        assert_eq!(BillingBlockManager::get_color_for_cost(2.49), "green");
274        assert_eq!(BillingBlockManager::get_color_for_cost(2.5), "yellow");
275        assert_eq!(BillingBlockManager::get_color_for_cost(4.99), "yellow");
276        assert_eq!(BillingBlockManager::get_color_for_cost(5.0), "red");
277        assert_eq!(BillingBlockManager::get_color_for_cost(10.0), "red");
278    }
279
280    #[test]
281    fn test_blocks_for_date() {
282        let mut manager = BillingBlockManager::new();
283
284        // Feb 2
285        let ts1 = Utc.with_ymd_and_hms(2026, 2, 2, 2, 0, 0).unwrap();
286        manager.add_usage(&ts1, 1000, 500, 0, 0, 0.5);
287
288        let ts2 = Utc.with_ymd_and_hms(2026, 2, 2, 14, 0, 0).unwrap();
289        manager.add_usage(&ts2, 2000, 1000, 0, 0, 1.0);
290
291        // Feb 3
292        let ts3 = Utc.with_ymd_and_hms(2026, 2, 3, 7, 0, 0).unwrap();
293        manager.add_usage(&ts3, 500, 250, 0, 0, 0.25);
294
295        // Get blocks for Feb 2
296        let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 2).unwrap();
297        let blocks = manager.get_blocks_for_date(date);
298        assert_eq!(blocks.len(), 2);
299        assert_eq!(blocks[0].0.block_hour, 0); // Block 1
300        assert_eq!(blocks[1].0.block_hour, 10); // Block 3
301
302        // Get blocks for Feb 3
303        let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 3).unwrap();
304        let blocks = manager.get_blocks_for_date(date);
305        assert_eq!(blocks.len(), 1);
306        assert_eq!(blocks[0].0.block_hour, 5); // Block 2
307    }
308}