1use super::{DailyCostRow, csv_field};
21use chrono::{Datelike, Duration, NaiveDate};
22
23pub const HEADER: &[&str] = &[
26 "BilledCost",
27 "BillingAccountId",
28 "BillingAccountName",
29 "BillingCurrency",
30 "BillingPeriodEnd",
31 "BillingPeriodStart",
32 "ChargeCategory",
33 "ChargeClass",
34 "ChargeDescription",
35 "ChargePeriodEnd",
36 "ChargePeriodStart",
37 "ContractedCost",
38 "EffectiveCost",
39 "InvoiceIssuerName",
40 "ListCost",
41 "PricingQuantity",
42 "PricingUnit",
43 "ProviderName",
44 "PublisherName",
45 "ServiceCategory",
46 "ServiceName",
47 "CommitmentDiscountCategory",
49 "CommitmentDiscountId",
50 "CommitmentDiscountName",
51 "CommitmentDiscountStatus",
52 "CommitmentDiscountType",
53 "ConsumedQuantity",
54 "ConsumedUnit",
55 "ContractedUnitPrice",
56 "InvoiceIssuer",
57 "ListUnitPrice",
58 "PricingCategory",
59 "ChargeType",
60 "Provider",
61 "Publisher",
62 "RegionId",
63 "RegionName",
64 "ResourceID",
65 "ResourceName",
66 "ResourceType",
67 "SkuId",
68 "SkuPriceId",
69 "SubAccountId",
70 "SubAccountName",
71 "Tags",
72 "x_project",
74 "x_agent_role",
75 "x_model",
76 "x_tool",
77 "x_tokens_saved",
78];
79
80const PROVIDER: &str = "LeanCTX";
81const SERVICE_CATEGORY: &str = "AI and Machine Learning";
82const SERVICE_NAME: &str = "LeanCTX Context Engine";
83
84fn billing_period(date: &NaiveDate) -> (String, String) {
87 let start = date.with_day(1).expect("day 1 always valid");
88 let end = if start.month() == 12 {
89 NaiveDate::from_ymd_opt(start.year() + 1, 1, 1)
90 } else {
91 NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1)
92 }
93 .expect("first of next month always valid");
94 (iso(&start), iso(&end))
95}
96
97fn iso(d: &NaiveDate) -> String {
98 format!("{}T00:00:00Z", d.format("%Y-%m-%d"))
99}
100
101fn push_row(out: &mut String, fields: &[String]) {
102 let line = fields
103 .iter()
104 .map(|f| csv_field(f))
105 .collect::<Vec<_>>()
106 .join(",");
107 out.push_str(&line);
108 out.push('\n');
109}
110
111pub fn to_csv(rows: &[DailyCostRow]) -> String {
114 let mut out = String::new();
115 push_row(
116 &mut out,
117 &HEADER
118 .iter()
119 .map(std::string::ToString::to_string)
120 .collect::<Vec<_>>(),
121 );
122
123 for row in rows {
124 let Ok(date) = NaiveDate::parse_from_str(&row.date, "%Y-%m-%d") else {
125 continue;
126 };
127 let charge_start = iso(&date);
128 let charge_end = iso(&(date + Duration::days(1)));
129 let (bill_start, bill_end) = billing_period(&date);
130
131 let resource_id = format!("leanctx/{}/{}/{}", row.project, row.agent_role, row.model);
132 let tags = serde_json::json!({
133 "project": row.project,
134 "agent_role": row.agent_role,
135 "model": row.model,
136 "tool": row.tool,
137 })
138 .to_string();
139
140 let mut emit = |category: &str, cost: f64, qty: u64, desc: String| {
141 push_row(
142 &mut out,
143 &[
144 format!("{cost:.6}"), row.project.clone(), format!("LeanCTX project {}", row.project), "USD".into(), bill_end.clone(), bill_start.clone(), category.into(), String::new(), desc, charge_end.clone(), charge_start.clone(), format!("{cost:.6}"), format!("{cost:.6}"), PROVIDER.into(), format!("{cost:.6}"), format!("{qty}.0"), "tokens".into(), PROVIDER.into(), PROVIDER.into(), SERVICE_CATEGORY.into(), SERVICE_NAME.into(), String::new(), String::new(), String::new(), String::new(), String::new(), format!("{qty}.0"), "tokens".into(), String::new(), PROVIDER.into(), String::new(), "Standard".into(), category.into(), PROVIDER.into(), PROVIDER.into(), String::new(), String::new(), resource_id.clone(), resource_id.clone(), "context-engine".into(), row.model.clone(), format!("{}-input", row.model), row.agent_role.clone(), row.agent_role.clone(), tags.clone(), row.project.clone(),
192 row.agent_role.clone(),
193 row.model.clone(),
194 row.tool.clone(),
195 row.tokens_saved.to_string(),
196 ],
197 );
198 };
199
200 emit(
201 "Usage",
202 row.cost_usd,
203 row.tokens_actual,
204 format!("LLM context tokens via {} ({})", row.tool, row.model),
205 );
206 if row.tokens_saved > 0 {
207 emit(
208 "Credit",
209 -row.savings_usd,
210 row.tokens_saved,
211 format!(
212 "LeanCTX verified savings (hash-chained ledger) via {} ({})",
213 row.tool, row.model
214 ),
215 );
216 }
217 }
218 out
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn row() -> DailyCostRow {
226 DailyCostRow {
227 date: "2026-06-01".into(),
228 project: "proj_a".into(),
229 agent_role: "coder".into(),
230 model: "claude".into(),
231 tool: "ctx_read".into(),
232 tokens_actual: 400,
233 tokens_saved: 1600,
234 cost_usd: 0.001,
235 savings_usd: 0.004,
236 }
237 }
238
239 #[test]
240 fn emits_all_mandatory_columns() {
241 let csv = to_csv(&[row()]);
242 let header = csv.lines().next().unwrap();
243 assert_eq!(header.split(',').count(), HEADER.len());
244 for col in [
245 "BilledCost",
246 "ChargeCategory",
247 "ChargePeriodStart",
248 "ServiceName",
249 "PricingUnit",
250 ] {
251 assert!(header.contains(col), "missing {col}");
252 }
253 }
254
255 #[test]
256 fn usage_and_credit_rows_with_negative_savings() {
257 let csv = to_csv(&[row()]);
258 let lines: Vec<&str> = csv.lines().collect();
259 assert_eq!(lines.len(), 3, "header + usage + credit");
260 assert!(lines[1].contains("Usage"));
261 assert!(lines[2].contains("Credit"));
262 assert!(
263 lines[2].starts_with("-0.004"),
264 "credit is negative: {}",
265 lines[2]
266 );
267 }
268
269 #[test]
270 fn billing_period_handles_december() {
271 let d = NaiveDate::from_ymd_opt(2026, 12, 15).unwrap();
272 let (start, end) = billing_period(&d);
273 assert_eq!(start, "2026-12-01T00:00:00Z");
274 assert_eq!(end, "2027-01-01T00:00:00Z");
275 }
276
277 #[test]
278 fn charge_period_is_one_day() {
279 let csv = to_csv(&[row()]);
280 let usage = csv.lines().nth(1).unwrap();
281 assert!(usage.contains("2026-06-01T00:00:00Z"));
282 assert!(usage.contains("2026-06-02T00:00:00Z"));
283 }
284
285 #[test]
286 fn no_credit_row_when_nothing_saved() {
287 let mut r = row();
288 r.tokens_saved = 0;
289 let csv = to_csv(&[r]);
290 assert_eq!(csv.lines().count(), 2, "header + usage only");
291 }
292}