use super::{DailyCostRow, csv_field};
pub const HEADER: &[&str] = &[
"lineitem/type",
"time/usage_start",
"resource/service",
"resource/id",
"resource/account",
"usage/amount",
"usage/units",
"cost/cost",
"resource/tag:project",
"resource/tag:agent_role",
"resource/tag:model",
"resource/tag:tool",
];
const SERVICE: &str = "LeanCTX";
fn record(row: &DailyCostRow, line_type: &str, amount: u64, cost: f64) -> Vec<String> {
vec![
line_type.to_string(),
format!("{}T00:00:00Z", row.date),
SERVICE.to_string(),
format!("leanctx/{}/{}/{}", row.project, row.agent_role, row.model),
row.project.clone(),
amount.to_string(),
"tokens".to_string(),
format!("{cost:.6}"),
row.project.clone(),
row.agent_role.clone(),
row.model.clone(),
row.tool.clone(),
]
}
fn records(rows: &[DailyCostRow]) -> Vec<Vec<String>> {
let mut out = Vec::new();
for row in rows {
out.push(record(row, "Usage", row.tokens_actual, row.cost_usd));
if row.tokens_saved > 0 {
out.push(record(row, "Discount", row.tokens_saved, -row.savings_usd));
}
}
out
}
pub fn to_csv(rows: &[DailyCostRow]) -> String {
let mut out = HEADER.join(",");
out.push('\n');
for rec in records(rows) {
let line = rec
.iter()
.map(|f| csv_field(f))
.collect::<Vec<_>>()
.join(",");
out.push_str(&line);
out.push('\n');
}
out
}
pub fn to_stream_body(rows: &[DailyCostRow], month: &str) -> serde_json::Value {
let data: Vec<serde_json::Value> = records(rows)
.into_iter()
.map(|rec| {
let mut obj = serde_json::Map::new();
for (key, val) in HEADER.iter().zip(rec) {
obj.insert((*key).to_string(), serde_json::Value::String(val));
}
serde_json::Value::Object(obj)
})
.collect();
serde_json::json!({
"month": month,
"operation": "replace_drop",
"data": data,
})
}
pub fn upload(rows: &[DailyCostRow], month: &str) -> Result<String, String> {
let api_key =
std::env::var("CLOUDZERO_API_KEY").map_err(|_| "CLOUDZERO_API_KEY not set".to_string())?;
let connection = std::env::var("CLOUDZERO_CONNECTION_ID")
.map_err(|_| "CLOUDZERO_CONNECTION_ID not set".to_string())?;
let url = format!(
"https://api.cloudzero.com/v2/connections/billing/anycost/{connection}/billing_drops"
);
let body = serde_json::to_vec(&to_stream_body(rows, month)).map_err(|e| e.to_string())?;
let agent = ureq::Agent::new_with_config(
ureq::config::Config::builder()
.timeout_global(Some(std::time::Duration::from_secs(30)))
.http_status_as_error(false)
.build(),
);
let resp = agent
.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", api_key.trim())
.send(body.as_slice())
.map_err(|e| format!("cloudzero unreachable: {e}"))?;
let status = resp.status().as_u16();
let text = resp.into_body().read_to_string().unwrap_or_default();
if (200..300).contains(&status) {
Ok(format!("CloudZero accepted month {month} ({status})"))
} else {
Err(format!("CloudZero rejected ({status}): {text}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row() -> DailyCostRow {
DailyCostRow {
date: "2026-06-01".into(),
project: "proj_a".into(),
agent_role: "coder".into(),
model: "claude".into(),
tool: "ctx_read".into(),
tokens_actual: 400,
tokens_saved: 1600,
cost_usd: 0.001,
savings_usd: 0.004,
}
}
#[test]
fn usage_and_discount_rows() {
let csv = to_csv(&[row()]);
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 3);
assert!(lines[1].starts_with("Usage,2026-06-01T00:00:00Z"));
assert!(lines[2].starts_with("Discount,"));
assert!(
lines[2].contains("-0.004"),
"discount negative: {}",
lines[2]
);
}
#[test]
fn stream_body_is_idempotent_replace_drop() {
let body = to_stream_body(&[row()], "2026-06");
assert_eq!(body["month"], "2026-06");
assert_eq!(body["operation"], "replace_drop");
let data = body["data"].as_array().unwrap();
assert_eq!(data.len(), 2);
assert_eq!(data[0]["lineitem/type"], "Usage");
assert_eq!(data[0]["cost/cost"], "0.001000");
assert_eq!(data[1]["resource/tag:project"], "proj_a");
}
#[test]
fn required_cbf_columns_present() {
let body = to_stream_body(&[row()], "2026-06");
let first = &body["data"][0];
assert!(first.get("time/usage_start").is_some());
assert!(first.get("cost/cost").is_some());
}
}