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 {
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)
}
#[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);
}
#[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());
}
}