Skip to main content

lean_ctx/core/finops_export/
focus.rs

1//! FOCUS CSV serializer (FinOps Open Cost & Usage Specification).
2//!
3//! Spec pinned: FOCUS v1.2 (published June 2024,
4//! <https://focus.finops.org/focus-specification/v1-2/>) — chosen over 1.3/1.4
5//! because 1.2 introduced the SaaS columns (token-denominated pricing) and is
6//! the version Vantage validates against for custom providers. All 21 v1.2
7//! Mandatory columns are emitted, **plus** the v1.0 required column set
8//! (`Provider`, `InvoiceIssuer`, `ResourceID`, `SubAccountId`, `Tags`, …,
9//! mostly nullable) so the official `focus-validator` (pip, validates
10//! against 1.0) passes the same file — additive columns are explicitly
11//! allowed by the spec. lean-ctx dimensions ride in `x_`-prefixed custom
12//! columns as the extensibility rules require.
13//!
14//! Two rows per [`DailyCostRow`]:
15//! - `ChargeCategory=Usage`: the actual token spend through lean-ctx.
16//! - `ChargeCategory=Credit`: verified savings as a negative cost (FOCUS's
17//!   category for granted reductions) — never mixed into Usage so budgets
18//!   stay clean.
19
20use super::{DailyCostRow, csv_field};
21use chrono::{Datelike, Duration, NaiveDate};
22
23/// The 21 FOCUS 1.2 Mandatory columns (spec order: alphabetical), the v1.0
24/// required/nullable compatibility set, then the `x_` custom dimensions.
25pub 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    // FOCUS 1.0 compatibility (required there, superseded/renamed in 1.2).
48    "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    // lean-ctx custom dimensions.
73    "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
84/// Month boundaries for the billing period: first of the charge month and
85/// first of the following month, ISO 8601 UTC.
86fn 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
111/// Serialize rows to a FOCUS 1.2 CSV document. Rows with unparseable dates
112/// are skipped (the aggregate layer already filters malformed timestamps).
113pub 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}"),                       // BilledCost
145                    row.project.clone(),                        // BillingAccountId
146                    format!("LeanCTX project {}", row.project), // BillingAccountName
147                    "USD".into(),                               // BillingCurrency
148                    bill_end.clone(),                           // BillingPeriodEnd
149                    bill_start.clone(),                         // BillingPeriodStart
150                    category.into(),                            // ChargeCategory
151                    String::new(),                              // ChargeClass (null)
152                    desc,                                       // ChargeDescription
153                    charge_end.clone(),                         // ChargePeriodEnd
154                    charge_start.clone(),                       // ChargePeriodStart
155                    format!("{cost:.6}"),                       // ContractedCost
156                    format!("{cost:.6}"),                       // EffectiveCost
157                    PROVIDER.into(),                            // InvoiceIssuerName
158                    format!("{cost:.6}"),                       // ListCost
159                    format!("{qty}.0"),                         // PricingQuantity (decimal)
160                    "tokens".into(),                            // PricingUnit
161                    PROVIDER.into(),                            // ProviderName
162                    PROVIDER.into(),                            // PublisherName
163                    SERVICE_CATEGORY.into(),                    // ServiceCategory
164                    SERVICE_NAME.into(),                        // ServiceName
165                    // FOCUS 1.0 compatibility block.
166                    String::new(),                  // CommitmentDiscountCategory
167                    String::new(),                  // CommitmentDiscountId
168                    String::new(),                  // CommitmentDiscountName
169                    String::new(),                  // CommitmentDiscountStatus
170                    String::new(),                  // CommitmentDiscountType
171                    format!("{qty}.0"),             // ConsumedQuantity (decimal)
172                    "tokens".into(),                // ConsumedUnit
173                    String::new(),                  // ContractedUnitPrice
174                    PROVIDER.into(),                // InvoiceIssuer
175                    String::new(),                  // ListUnitPrice
176                    "Standard".into(),              // PricingCategory
177                    category.into(),                // ChargeType (1.0 name for ChargeCategory)
178                    PROVIDER.into(),                // Provider
179                    PROVIDER.into(),                // Publisher
180                    String::new(),                  // RegionId
181                    String::new(),                  // RegionName
182                    resource_id.clone(),            // ResourceID (1.0 spelling)
183                    resource_id.clone(),            // ResourceName
184                    "context-engine".into(),        // ResourceType
185                    row.model.clone(),              // SkuId (model = the priced SKU)
186                    format!("{}-input", row.model), // SkuPriceId
187                    row.agent_role.clone(),         // SubAccountId
188                    row.agent_role.clone(),         // SubAccountName
189                    tags.clone(),                   // Tags
190                    // lean-ctx custom dimensions.
191                    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}