1use chrono::{DateTime, Timelike, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct BillingBlock {
15 pub date: chrono::NaiveDate,
17 pub block_hour: u8,
19}
20
21impl BillingBlock {
22 pub fn from_timestamp(timestamp: &DateTime<Utc>) -> Self {
36 let date = timestamp.date_naive();
37 let hour = timestamp.hour() as u8;
38
39 let block_hour = (hour / 5) * 5;
42
43 BillingBlock { date, block_hour }
44 }
45
46 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 pub fn block_number(&self) -> u8 {
58 (self.block_hour / 5) + 1
59 }
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct BillingBlockUsage {
65 pub input_tokens: u64,
67 pub output_tokens: u64,
69 pub cache_creation_tokens: u64,
71 pub cache_read_tokens: u64,
73 pub total_cost: f64,
75 pub session_count: usize,
77}
78
79impl BillingBlockUsage {
80 pub fn total_tokens(&self) -> u64 {
85 self.input_tokens + self.output_tokens + self.cache_creation_tokens + self.cache_read_tokens
86 }
87
88 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#[derive(Debug, Default)]
101pub struct BillingBlockManager {
102 blocks: HashMap<BillingBlock, BillingBlockUsage>,
104}
105
106impl BillingBlockManager {
107 pub fn new() -> Self {
108 Self {
109 blocks: HashMap::new(),
110 }
111 }
112
113 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 pub fn get_usage(&self, block: &BillingBlock) -> Option<&BillingBlockUsage> {
136 self.blocks.get(block)
137 }
138
139 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 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 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 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 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 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 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 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 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 manager.add_usage(&ts1, 500, 250, 50, 25, 0.25);
241
242 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 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 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 let all_blocks = manager.get_all_blocks();
265 assert_eq!(all_blocks.len(), 2);
266 assert_eq!(all_blocks[0].0.block_hour, 0); assert_eq!(all_blocks[1].0.block_hour, 5); }
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 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 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 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); assert_eq!(blocks[1].0.block_hour, 10); 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); }
308}