use serde::Deserialize;
use crate::error::{AppError, Result};
use crate::usage::parse_amount;
#[derive(Debug, Clone, Deserialize)]
pub struct CostReport {
pub data: Vec<Bucket>,
pub has_more: bool,
#[serde(default)]
pub next_page: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Bucket {
#[serde(default)]
pub ending_at: Option<String>,
pub results: Vec<CostResult>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CostResult {
pub amount: String,
pub currency: String,
}
pub fn page_dollars(report: &CostReport) -> Result<f64> {
let mut cents = 0.0_f64;
for r in report.data.iter().flat_map(|b| b.results.iter()) {
if r.currency != "USD" {
return Err(AppError::Schema(format!(
"anthropic-api: unsupported cost_report currency {:?}; expected USD",
r.currency
)));
}
cents += parse_amount("anthropic-api", "cost_report.amount", &r.amount)?;
}
crate::usage::finite_amount("anthropic-api", "cost_report total", cents)?;
Ok(cents / 100.0)
}
pub fn page_covered_through(report: &CostReport) -> Result<Option<chrono::NaiveDate>> {
report
.data
.iter()
.filter_map(|bucket| bucket.ending_at.as_deref())
.map(|raw| {
chrono::DateTime::parse_from_rfc3339(raw)
.map_err(|e| {
AppError::Schema(format!(
"anthropic-api: invalid cost_report ending_at {raw:?}: {e}"
))
})
.and_then(|ending| {
ending
.with_timezone(&chrono::Utc)
.date_naive()
.pred_opt()
.ok_or_else(|| {
AppError::Schema(
"anthropic-api: cost_report ending_at underflow".into(),
)
})
})
})
.collect::<Result<Vec<_>>>()
.map(|dates| dates.into_iter().max())
}
#[cfg(test)]
mod tests {
use super::*;
const BODY: &str = r#"{
"data": [
{ "starting_at": "2026-07-01T00:00:00Z", "ending_at": "2026-07-02T00:00:00Z",
"results": [
{ "amount": "100.0", "currency": "USD", "cost_type": "tokens" },
{ "amount": "34.5", "currency": "USD", "cost_type": "web_search" }
] }
],
"has_more": false,
"next_page": null
}"#;
#[test]
fn sums_cents_and_converts_to_dollars() {
let report: CostReport = serde_json::from_str(BODY).unwrap();
assert!((page_dollars(&report).unwrap() - 1.345).abs() < 1e-9);
assert!(!report.has_more);
assert_eq!(
page_covered_through(&report).unwrap(),
Some(chrono::NaiveDate::from_ymd_opt(2026, 7, 1).unwrap())
);
}
#[test]
fn invalid_bucket_end_is_schema_drift_not_a_false_coverage_claim() {
let report: CostReport = serde_json::from_str(
r#"{"data":[{"ending_at":"today","results":[]}],"has_more":false}"#,
)
.unwrap();
assert!(page_covered_through(&report).is_err());
}
#[test]
fn a_month_with_no_usage_is_genuinely_zero() {
let report: CostReport =
serde_json::from_str(r#"{"data":[],"has_more":false,"next_page":null}"#).unwrap();
assert_eq!(page_dollars(&report).unwrap(), 0.0);
let report2: CostReport =
serde_json::from_str(r#"{"data":[{"results":[]}],"has_more":false}"#).unwrap();
assert_eq!(page_dollars(&report2).unwrap(), 0.0);
}
#[test]
fn malformed_envelope_is_rejected_rather_than_read_as_zero_spend() {
assert!(serde_json::from_str::<CostReport>("{}").is_err());
assert!(
serde_json::from_str::<CostReport>(r#"{"error":{"message":"invalid x-api-key"}}"#)
.is_err()
);
assert!(serde_json::from_str::<CostReport>(r#"{"data":[]}"#).is_err());
assert!(serde_json::from_str::<CostReport>(r#"{"data":[{}],"has_more":false}"#).is_err());
assert!(
serde_json::from_str::<CostReport>(
r#"{"data":[{"results":[{"cost":"1"}]}],"has_more":false}"#
)
.is_err()
);
}
#[test]
fn non_numeric_amount_is_a_schema_error() {
for bad in [r#""""#, r#""n/a""#, r#"" ""#] {
let body = format!(
r#"{{"data":[{{"results":[{{"amount":{bad},"currency":"USD"}}]}}],"has_more":false}}"#
);
let report: CostReport = serde_json::from_str(&body).unwrap();
assert!(
page_dollars(&report).is_err(),
"amount {bad} should not parse as spend"
);
}
}
#[test]
fn non_finite_amount_is_rejected() {
let body = r#"{"data":[{"results":[{"amount":"inf","currency":"USD"}]}],"has_more":false}"#;
let report: CostReport = serde_json::from_str(body).unwrap();
assert!(page_dollars(&report).is_err());
}
#[test]
fn non_usd_or_missing_currency_is_rejected() {
let non_usd =
r#"{"data":[{"results":[{"amount":"100","currency":"EUR"}]}],"has_more":false}"#;
let report: CostReport = serde_json::from_str(non_usd).unwrap();
assert!(page_dollars(&report).is_err());
let missing = r#"{"data":[{"results":[{"amount":"100"}]}],"has_more":false}"#;
assert!(serde_json::from_str::<CostReport>(missing).is_err());
}
}