use chrono::{DateTime, Timelike, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BillingBlock {
pub date: chrono::NaiveDate,
pub block_hour: u8,
}
impl BillingBlock {
pub fn from_timestamp(timestamp: &DateTime<Utc>) -> Self {
let date = timestamp.date_naive();
let hour = timestamp.hour() as u8;
let block_hour = (hour / 5) * 5;
BillingBlock { date, block_hour }
}
pub fn label(&self) -> String {
let end_hour = if self.block_hour == 20 {
23
} else {
self.block_hour + 4
};
format!("{:02}:00-{:02}:59", self.block_hour, end_hour)
}
pub fn block_number(&self) -> u8 {
(self.block_hour / 5) + 1
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BillingBlockUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_read_tokens: u64,
pub total_cost: f64,
pub session_count: usize,
}
impl BillingBlockUsage {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_creation_tokens + self.cache_read_tokens
}
pub fn add(&mut self, other: &BillingBlockUsage) {
self.input_tokens += other.input_tokens;
self.output_tokens += other.output_tokens;
self.cache_creation_tokens += other.cache_creation_tokens;
self.cache_read_tokens += other.cache_read_tokens;
self.total_cost += other.total_cost;
self.session_count += other.session_count;
}
}
#[derive(Debug, Default)]
pub struct BillingBlockManager {
blocks: HashMap<BillingBlock, BillingBlockUsage>,
}
impl BillingBlockManager {
pub fn new() -> Self {
Self {
blocks: HashMap::new(),
}
}
pub fn add_usage(
&mut self,
timestamp: &DateTime<Utc>,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
cost: f64,
) {
let block = BillingBlock::from_timestamp(timestamp);
let usage = self.blocks.entry(block).or_default();
usage.input_tokens += input_tokens;
usage.output_tokens += output_tokens;
usage.cache_creation_tokens += cache_creation_tokens;
usage.cache_read_tokens += cache_read_tokens;
usage.total_cost += cost;
usage.session_count += 1;
}
pub fn get_usage(&self, block: &BillingBlock) -> Option<&BillingBlockUsage> {
self.blocks.get(block)
}
pub fn get_all_blocks(&self) -> Vec<(BillingBlock, BillingBlockUsage)> {
let mut blocks: Vec<_> = self
.blocks
.iter()
.map(|(block, usage)| (*block, usage.clone()))
.collect();
blocks.sort_by(|a, b| {
a.0.date
.cmp(&b.0.date)
.then_with(|| a.0.block_hour.cmp(&b.0.block_hour))
});
blocks
}
pub fn get_blocks_for_date(
&self,
date: chrono::NaiveDate,
) -> Vec<(BillingBlock, BillingBlockUsage)> {
let mut blocks: Vec<_> = self
.blocks
.iter()
.filter(|(block, _)| block.date == date)
.map(|(block, usage)| (*block, usage.clone()))
.collect();
blocks.sort_by_key(|(block, _)| block.block_hour);
blocks
}
pub fn get_color_for_cost(cost: f64) -> &'static str {
if cost < 2.5 {
"green"
} else if cost < 5.0 {
"yellow"
} else {
"red"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_billing_block_normalization() {
let ts = Utc.with_ymd_and_hms(2026, 2, 2, 2, 30, 0).unwrap();
let block = BillingBlock::from_timestamp(&ts);
assert_eq!(block.block_hour, 0);
assert_eq!(block.label(), "00:00-04:59");
assert_eq!(block.block_number(), 1);
let ts = Utc.with_ymd_and_hms(2026, 2, 2, 7, 15, 0).unwrap();
let block = BillingBlock::from_timestamp(&ts);
assert_eq!(block.block_hour, 5);
assert_eq!(block.label(), "05:00-09:59");
assert_eq!(block.block_number(), 2);
let ts = Utc.with_ymd_and_hms(2026, 2, 2, 14, 59, 0).unwrap();
let block = BillingBlock::from_timestamp(&ts);
assert_eq!(block.block_hour, 10);
assert_eq!(block.label(), "10:00-14:59");
assert_eq!(block.block_number(), 3);
let ts = Utc.with_ymd_and_hms(2026, 2, 2, 18, 0, 0).unwrap();
let block = BillingBlock::from_timestamp(&ts);
assert_eq!(block.block_hour, 15);
assert_eq!(block.label(), "15:00-19:59");
assert_eq!(block.block_number(), 4);
let ts = Utc.with_ymd_and_hms(2026, 2, 2, 23, 59, 59).unwrap();
let block = BillingBlock::from_timestamp(&ts);
assert_eq!(block.block_hour, 20);
assert_eq!(block.label(), "20:00-23:59");
assert_eq!(block.block_number(), 5);
}
#[test]
fn test_billing_block_manager() {
let mut manager = BillingBlockManager::new();
let ts1 = Utc.with_ymd_and_hms(2026, 2, 2, 2, 0, 0).unwrap();
manager.add_usage(&ts1, 1000, 500, 100, 50, 0.5);
manager.add_usage(&ts1, 500, 250, 50, 25, 0.25);
let ts2 = Utc.with_ymd_and_hms(2026, 2, 2, 7, 0, 0).unwrap();
manager.add_usage(&ts2, 2000, 1000, 200, 100, 1.0);
let block1 = BillingBlock::from_timestamp(&ts1);
let usage1 = manager.get_usage(&block1).unwrap();
assert_eq!(usage1.input_tokens, 1500);
assert_eq!(usage1.output_tokens, 750);
assert_eq!(usage1.cache_creation_tokens, 150);
assert_eq!(usage1.cache_read_tokens, 75);
assert_eq!(usage1.total_cost, 0.75);
assert_eq!(usage1.session_count, 2);
let block2 = BillingBlock::from_timestamp(&ts2);
let usage2 = manager.get_usage(&block2).unwrap();
assert_eq!(usage2.input_tokens, 2000);
assert_eq!(usage2.total_cost, 1.0);
assert_eq!(usage2.session_count, 1);
let all_blocks = manager.get_all_blocks();
assert_eq!(all_blocks.len(), 2);
assert_eq!(all_blocks[0].0.block_hour, 0); assert_eq!(all_blocks[1].0.block_hour, 5); }
#[test]
fn test_color_coding() {
assert_eq!(BillingBlockManager::get_color_for_cost(1.0), "green");
assert_eq!(BillingBlockManager::get_color_for_cost(2.49), "green");
assert_eq!(BillingBlockManager::get_color_for_cost(2.5), "yellow");
assert_eq!(BillingBlockManager::get_color_for_cost(4.99), "yellow");
assert_eq!(BillingBlockManager::get_color_for_cost(5.0), "red");
assert_eq!(BillingBlockManager::get_color_for_cost(10.0), "red");
}
#[test]
fn test_blocks_for_date() {
let mut manager = BillingBlockManager::new();
let ts1 = Utc.with_ymd_and_hms(2026, 2, 2, 2, 0, 0).unwrap();
manager.add_usage(&ts1, 1000, 500, 0, 0, 0.5);
let ts2 = Utc.with_ymd_and_hms(2026, 2, 2, 14, 0, 0).unwrap();
manager.add_usage(&ts2, 2000, 1000, 0, 0, 1.0);
let ts3 = Utc.with_ymd_and_hms(2026, 2, 3, 7, 0, 0).unwrap();
manager.add_usage(&ts3, 500, 250, 0, 0, 0.25);
let date = chrono::NaiveDate::from_ymd_opt(2026, 2, 2).unwrap();
let blocks = manager.get_blocks_for_date(date);
assert_eq!(blocks.len(), 2);
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();
let blocks = manager.get_blocks_for_date(date);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].0.block_hour, 5); }
}