Skip to main content

costroid_focus/
lib.rs

1//! FOCUS 1.3 Cost and Usage export primitives for Costroid.
2//!
3//! As of Milestone 6a, `FocusRecord` carries the full FOCUS 1.3 Cost and Usage
4//! column set so the official validator's conditional dependency checks resolve.
5//! Columns Costroid cannot derive from local data are emitted null where the spec
6//! permits; the few that a not-null cascade forces are populated with the
7//! spec-correct categorical value or, for billing-source identifiers with no local
8//! value, a clearly-non-billing placeholder (documented as a deviation).
9//!
10//! Numeric columns serialize as genuine numbers in JSON and as decimal-pointed
11//! values in CSV (so the validator's DECIMAL/DOUBLE/FLOAT type checks pass even
12//! when every value in a column is whole).
13//!
14//! As of Milestone 6b, pricing is represented per token: `PricingUnit = "tokens"`,
15//! `PricingQuantity` is the token count, and the unit-price columns are per-token
16//! rates (the per-1M catalog rate ÷ 1_000_000). Cost is unchanged — `cost = tokens
17//! × rate` is invariant — only the representation changed. On rows with no priced
18//! SKU (`SkuPriceId` null), FOCUS 1.3 requires `ConsumedQuantity` / `PricingQuantity`
19//! / `PricingUnit` / `PricingCategory` to be null, so they are; the raw token count
20//! still travels on the always-populated `x_ConsumedTokens` custom column for the
21//! aggregation engine. One genuine validator-ruleset defect remains documented (the
22//! `ListCost`/`ContractedCost` = unit-price × quantity check, which the validator
23//! evaluates in zero-tolerance float64 even though Costroid's decimal arithmetic is
24//! exact); see `scripts/focus_known_failures.txt`.
25
26use std::cell::Cell;
27
28use chrono::{DateTime, Datelike, Duration, LocalResult, TimeZone, Timelike, Utc};
29use rust_decimal::Decimal;
30use serde::{Deserialize, Serialize, Serializer};
31use serde_json::value::RawValue;
32use thiserror::Error;
33
34pub const FOCUS_VERSION: &str = "1.3";
35pub const DEFAULT_BILLING_CURRENCY: &str = "USD";
36pub const CHARGE_CATEGORY_USAGE: &str = "Usage";
37pub const CHARGE_FREQUENCY_USAGE_BASED: &str = "Usage-Based";
38pub const PRICING_CATEGORY_STANDARD: &str = "Standard";
39/// FOCUS `PricingUnit` / `ConsumedUnit` for per-token AI usage. Singular count
40/// unit (no numeric multiplier) so it conforms to the FOCUS UnitFormat.
41pub const PRICING_UNIT_TOKENS: &str = "tokens";
42pub const SERVICE_CATEGORY_AI: &str = "AI and Machine Learning";
43/// Valid FOCUS `ServiceSubcategory` paired with `ServiceCategory = "AI and Machine
44/// Learning"`. Costroid's three providers are LLM coding tools, so this is the
45/// correct classification — not a deviation.
46pub const SERVICE_SUBCATEGORY_GENERATIVE_AI: &str = "Generative AI";
47pub const PRICING_STATUS_MISSING_PRICE: &str = "missing_price";
48
49/// Placeholder `BillingAccountId`. FOCUS requires `BillingAccountId` to be
50/// non-null, but Costroid is a local estimator with no billing-account identity.
51/// This obviously-non-billing sentinel is a documented deviation; Costroid never
52/// fabricates realistic-looking account identifiers.
53pub const BILLING_ACCOUNT_ID_LOCAL: &str = "costroid-local-estimate";
54/// Placeholder `BillingAccountName`, paired with [`BILLING_ACCOUNT_ID_LOCAL`].
55pub const BILLING_ACCOUNT_NAME_LOCAL: &str = "Costroid local estimate";
56/// Placeholder `BillingAccountType`. FOCUS forces this non-null whenever
57/// `BillingAccountId` is non-null; since our account id is itself a placeholder,
58/// the type is too (documented deviation).
59pub const BILLING_ACCOUNT_TYPE_LOCAL: &str = "Local estimate";
60
61pub type FocusTimestamp = DateTime<Utc>;
62
63#[derive(Debug, Error)]
64pub enum FocusError {
65    #[error("invalid timestamp for FOCUS period calculation")]
66    InvalidTimestamp,
67
68    #[error("failed to serialize FOCUS JSON: {0}")]
69    Json(#[from] serde_json::Error),
70
71    #[error("failed to serialize FOCUS CSV: {0}")]
72    Csv(#[from] csv::Error),
73
74    #[error("failed to flush FOCUS CSV: {0}")]
75    Io(#[from] std::io::Error),
76
77    #[error("failed to convert FOCUS CSV to UTF-8: {0}")]
78    Utf8(#[from] std::string::FromUtf8Error),
79}
80
81// --- Numeric serialization mode ---------------------------------------------
82//
83// FOCUS numeric columns must be real numbers. JSON emits them as unquoted number
84// tokens (via `RawValue`); CSV emits them as decimal-pointed strings so the
85// validator's column type inference reads DOUBLE rather than INTEGER even when a
86// whole column is integer-valued (e.g. all-zero unpriced costs, token counts).
87// The two encodings diverge, so a thread-local selects which one the shared
88// `serialize_with` hooks produce. `to_json_string` / `to_csv_string` set it.
89
90#[derive(Clone, Copy, PartialEq, Eq)]
91enum SerMode {
92    Json,
93    Csv,
94}
95
96thread_local! {
97    static SER_MODE: Cell<SerMode> = const { Cell::new(SerMode::Json) };
98}
99
100struct SerModeGuard(SerMode);
101
102impl SerModeGuard {
103    fn new(mode: SerMode) -> Self {
104        SerModeGuard(SER_MODE.with(|m| m.replace(mode)))
105    }
106}
107
108impl Drop for SerModeGuard {
109    fn drop(&mut self) {
110        SER_MODE.with(|m| m.set(self.0));
111    }
112}
113
114/// Render a decimal so it is unambiguously a decimal value: always carries a
115/// `.`-separated fractional part. `rust_decimal` never uses scientific notation,
116/// so this is safe for both JSON numbers and CSV.
117fn decimal_with_point(value: &Decimal) -> String {
118    let rendered = value.to_string();
119    if rendered.contains('.') {
120        rendered
121    } else {
122        format!("{rendered}.0")
123    }
124}
125
126fn serialize_decimal<S: Serializer>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error> {
127    match SER_MODE.with(Cell::get) {
128        SerMode::Csv => serializer.serialize_str(&decimal_with_point(value)),
129        SerMode::Json => RawValue::from_string(decimal_with_point(value))
130            .map_err(serde::ser::Error::custom)?
131            .serialize(serializer),
132    }
133}
134
135fn serialize_decimal_opt<S: Serializer>(
136    value: &Option<Decimal>,
137    serializer: S,
138) -> Result<S::Ok, S::Error> {
139    match value {
140        Some(value) => serialize_decimal(value, serializer),
141        None => serializer.serialize_none(),
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct FocusExportEnvelope<T> {
147    #[serde(rename = "focusVersion")]
148    pub focus_version: String,
149    pub rows: Vec<T>,
150}
151
152impl<T> FocusExportEnvelope<T> {
153    pub fn new(rows: Vec<T>) -> Self {
154        Self {
155            focus_version: FOCUS_VERSION.to_string(),
156            rows,
157        }
158    }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum FocusAccessPath {
164    Api,
165    Subscription,
166    Unknown,
167}
168
169impl FocusAccessPath {
170    pub fn as_str(self) -> &'static str {
171        match self {
172            Self::Api => "api",
173            Self::Subscription => "subscription",
174            Self::Unknown => "unknown",
175        }
176    }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum TokenType {
182    Input,
183    Output,
184    CacheRead,
185    CacheWrite,
186}
187
188impl TokenType {
189    pub fn as_str(self) -> &'static str {
190        match self {
191            Self::Input => "input",
192            Self::Output => "output",
193            Self::CacheRead => "cache_read",
194            Self::CacheWrite => "cache_write",
195        }
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct UnpricedUsage {
201    pub timestamp: DateTime<Utc>,
202    pub tool: String,
203    pub model: String,
204    pub token_type: TokenType,
205    pub token_count: u64,
206    pub project: Option<String>,
207    pub access_path: FocusAccessPath,
208    pub service_name: String,
209    pub service_provider_name: String,
210    pub host_provider_name: String,
211    pub invoice_issuer_name: String,
212    pub billing_currency: String,
213}
214
215/// A FOCUS 1.3 Cost and Usage charge row.
216///
217/// Field order is the serialized column order. The full FOCUS 1.3 column set
218/// comes first (PascalCase via serde), Costroid's custom `x_` columns last.
219/// Numeric columns use [`serialize_decimal`] / [`serialize_decimal_opt`].
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(rename_all = "PascalCase")]
222pub struct FocusRecord {
223    // Costs (BillingCurrency).
224    #[serde(serialize_with = "serialize_decimal")]
225    pub billed_cost: Decimal,
226    #[serde(serialize_with = "serialize_decimal")]
227    pub effective_cost: Decimal,
228    #[serde(serialize_with = "serialize_decimal")]
229    pub list_cost: Decimal,
230    #[serde(serialize_with = "serialize_decimal")]
231    pub contracted_cost: Decimal,
232
233    // Billing account (no local billing identity — documented placeholders).
234    pub billing_account_id: String,
235    pub billing_account_name: String,
236    pub billing_account_type: Option<String>,
237    pub billing_currency: String,
238
239    // Time.
240    pub billing_period_start: DateTime<Utc>,
241    pub billing_period_end: DateTime<Utc>,
242    pub charge_period_start: DateTime<Utc>,
243    pub charge_period_end: DateTime<Utc>,
244
245    // Charge classification.
246    pub charge_category: String,
247    pub charge_class: Option<String>,
248    pub charge_description: String,
249    pub charge_frequency: String,
250
251    // Service & provider. ProviderName/PublisherName are deprecated in 1.3 but
252    // the validator still requires them present; they mirror the active
253    // participating-entity columns.
254    pub service_name: String,
255    pub service_category: String,
256    pub service_subcategory: Option<String>,
257    pub service_provider_name: String,
258    pub host_provider_name: String,
259    pub invoice_issuer_name: String,
260    pub provider_name: String,
261    pub publisher_name: String,
262    pub invoice_id: Option<String>,
263
264    // SKU / pricing.
265    pub sku_id: Option<String>,
266    pub sku_price_id: Option<String>,
267    pub sku_meter: Option<String>,
268    pub sku_price_details: Option<String>,
269    // PricingCategory / PricingQuantity / PricingUnit are null on rows with no
270    // priced SKU (SkuPriceId null) per FOCUS 1.3; populated on priced rows.
271    pub pricing_category: Option<String>,
272    pub pricing_currency: String,
273    #[serde(serialize_with = "serialize_decimal_opt")]
274    pub pricing_quantity: Option<Decimal>,
275    pub pricing_unit: Option<String>,
276    #[serde(serialize_with = "serialize_decimal_opt")]
277    pub list_unit_price: Option<Decimal>,
278    #[serde(serialize_with = "serialize_decimal_opt")]
279    pub contracted_unit_price: Option<Decimal>,
280    #[serde(serialize_with = "serialize_decimal_opt")]
281    pub pricing_currency_list_unit_price: Option<Decimal>,
282    #[serde(serialize_with = "serialize_decimal_opt")]
283    pub pricing_currency_contracted_unit_price: Option<Decimal>,
284    #[serde(serialize_with = "serialize_decimal")]
285    pub pricing_currency_effective_cost: Decimal,
286
287    // Consumption. ConsumedQuantity is null on rows with no priced SKU
288    // (SkuPriceId null) per FOCUS 1.3; the raw count lives on x_ConsumedTokens.
289    #[serde(serialize_with = "serialize_decimal_opt")]
290    pub consumed_quantity: Option<Decimal>,
291    pub consumed_unit: String,
292
293    // FOCUS columns Costroid cannot derive from local logs (emitted null).
294    pub commitment_discount_category: Option<String>,
295    pub commitment_discount_id: Option<String>,
296    pub commitment_discount_name: Option<String>,
297    #[serde(serialize_with = "serialize_decimal_opt")]
298    pub commitment_discount_quantity: Option<Decimal>,
299    pub commitment_discount_status: Option<String>,
300    pub commitment_discount_type: Option<String>,
301    pub commitment_discount_unit: Option<String>,
302    pub capacity_reservation_id: Option<String>,
303    pub capacity_reservation_status: Option<String>,
304    pub region_id: Option<String>,
305    pub region_name: Option<String>,
306    pub availability_zone: Option<String>,
307    pub resource_id: Option<String>,
308    pub resource_name: Option<String>,
309    pub resource_type: Option<String>,
310    pub sub_account_id: Option<String>,
311    pub sub_account_name: Option<String>,
312    pub sub_account_type: Option<String>,
313    pub tags: Option<String>,
314    pub contract_applied: Option<String>,
315    pub allocated_method_id: Option<String>,
316    pub allocated_method_details: Option<String>,
317    pub allocated_resource_id: Option<String>,
318    pub allocated_resource_name: Option<String>,
319    pub allocated_tags: Option<String>,
320
321    // Custom (x_ prefix per FOCUS).
322    #[serde(rename = "x_Model")]
323    pub x_model: String,
324    #[serde(rename = "x_TokenType")]
325    pub x_token_type: String,
326    #[serde(rename = "x_AccessPath")]
327    pub x_access_path: String,
328    #[serde(rename = "x_Estimated")]
329    pub x_estimated: bool,
330    #[serde(rename = "x_Tool")]
331    pub x_tool: String,
332    #[serde(rename = "x_Project")]
333    pub x_project: Option<String>,
334    #[serde(rename = "x_PricingStatus")]
335    pub x_pricing_status: String,
336    /// Raw token count for this meter row, always populated (even on unpriced
337    /// rows where `ConsumedQuantity` must be null). The aggregation engine reads
338    /// this for token totals so nulling `ConsumedQuantity` never drops usage.
339    #[serde(rename = "x_ConsumedTokens", serialize_with = "serialize_decimal")]
340    pub x_consumed_tokens: Decimal,
341}
342
343impl FocusRecord {
344    pub fn unpriced_usage(input: UnpricedUsage) -> Result<Self, FocusError> {
345        // Instantaneous transcript turns are point-in-time. FOCUS uses an
346        // inclusive start / exclusive end, so end = start + 1s. Truncate to whole
347        // seconds (FOCUS DateTimeFormat is second-granular).
348        let charge_period_start = input
349            .timestamp
350            .with_nanosecond(0)
351            .unwrap_or(input.timestamp);
352        let (billing_period_start, billing_period_end) = billing_period(charge_period_start)?;
353        let charge_period_end = charge_period_start
354            .checked_add_signed(Duration::seconds(1))
355            .ok_or(FocusError::InvalidTimestamp)?;
356        let token_type = input.token_type.as_str();
357        let cost = Decimal::from(0);
358        let consumed_tokens = Decimal::from(input.token_count);
359
360        Ok(Self {
361            billed_cost: cost,
362            effective_cost: cost,
363            list_cost: cost,
364            contracted_cost: cost,
365            billing_account_id: BILLING_ACCOUNT_ID_LOCAL.to_string(),
366            billing_account_name: BILLING_ACCOUNT_NAME_LOCAL.to_string(),
367            billing_account_type: Some(BILLING_ACCOUNT_TYPE_LOCAL.to_string()),
368            billing_currency: input.billing_currency.clone(),
369            billing_period_start,
370            billing_period_end,
371            charge_period_start,
372            charge_period_end,
373            charge_category: CHARGE_CATEGORY_USAGE.to_string(),
374            charge_class: None,
375            charge_description: format!("{} {} tokens", input.model, token_type),
376            charge_frequency: CHARGE_FREQUENCY_USAGE_BASED.to_string(),
377            service_name: input.service_name,
378            service_category: SERVICE_CATEGORY_AI.to_string(),
379            service_subcategory: Some(SERVICE_SUBCATEGORY_GENERATIVE_AI.to_string()),
380            service_provider_name: input.service_provider_name.clone(),
381            host_provider_name: input.host_provider_name,
382            invoice_issuer_name: input.invoice_issuer_name.clone(),
383            provider_name: input.service_provider_name,
384            publisher_name: input.invoice_issuer_name,
385            invoice_id: None,
386            sku_id: Some(format!("{}:{token_type}", input.model)),
387            sku_price_id: None,
388            sku_meter: Some(token_type.to_string()),
389            sku_price_details: None,
390            // No priced SKU yet: FOCUS 1.3 requires PricingCategory / PricingQuantity
391            // / PricingUnit / ConsumedQuantity to be null when SkuPriceId is null.
392            // `apply_pricing` (costroid-core) populates them when a rate is found.
393            //
394            // NOTE: the "MUST NOT be null when Usage" sibling rules don't conflict
395            // only because Costroid leaves ChargeClass and CommitmentDiscountStatus
396            // null — populating either on an unpriced row would reintroduce the
397            // conflict. See docs/DATA-MODEL.md (unpriced-row convention).
398            pricing_category: None,
399            pricing_currency: input.billing_currency,
400            pricing_quantity: None,
401            pricing_unit: None,
402            list_unit_price: None,
403            contracted_unit_price: None,
404            pricing_currency_list_unit_price: None,
405            pricing_currency_contracted_unit_price: None,
406            pricing_currency_effective_cost: cost,
407            consumed_quantity: None,
408            consumed_unit: PRICING_UNIT_TOKENS.to_string(),
409            commitment_discount_category: None,
410            commitment_discount_id: None,
411            commitment_discount_name: None,
412            commitment_discount_quantity: None,
413            commitment_discount_status: None,
414            commitment_discount_type: None,
415            commitment_discount_unit: None,
416            capacity_reservation_id: None,
417            capacity_reservation_status: None,
418            region_id: None,
419            region_name: None,
420            availability_zone: None,
421            resource_id: None,
422            resource_name: None,
423            resource_type: None,
424            sub_account_id: None,
425            sub_account_name: None,
426            sub_account_type: None,
427            tags: None,
428            contract_applied: None,
429            allocated_method_id: None,
430            allocated_method_details: None,
431            allocated_resource_id: None,
432            allocated_resource_name: None,
433            allocated_tags: None,
434            x_model: input.model,
435            x_token_type: token_type.to_string(),
436            x_access_path: input.access_path.as_str().to_string(),
437            x_estimated: true,
438            x_tool: input.tool,
439            x_project: input.project,
440            x_pricing_status: PRICING_STATUS_MISSING_PRICE.to_string(),
441            x_consumed_tokens: consumed_tokens,
442        })
443    }
444}
445
446pub fn to_json_string(rows: Vec<FocusRecord>) -> Result<String, FocusError> {
447    let _guard = SerModeGuard::new(SerMode::Json);
448    let envelope = FocusExportEnvelope::new(rows);
449    serde_json::to_string_pretty(&envelope).map_err(FocusError::from)
450}
451
452pub fn to_csv_string(rows: &[FocusRecord]) -> Result<String, FocusError> {
453    let _guard = SerModeGuard::new(SerMode::Csv);
454    let mut writer = csv::Writer::from_writer(Vec::new());
455    for row in rows {
456        writer.serialize(row)?;
457    }
458    writer.flush()?;
459    let bytes = writer.get_ref().clone();
460    String::from_utf8(bytes).map_err(FocusError::from)
461}
462
463fn billing_period(timestamp: DateTime<Utc>) -> Result<(DateTime<Utc>, DateTime<Utc>), FocusError> {
464    let start = utc_datetime(timestamp.year(), timestamp.month(), 1)?;
465    let (next_year, next_month) = if timestamp.month() == 12 {
466        (timestamp.year() + 1, 1)
467    } else {
468        (timestamp.year(), timestamp.month() + 1)
469    };
470    let end = utc_datetime(next_year, next_month, 1)?;
471    Ok((start, end))
472}
473
474fn utc_datetime(year: i32, month: u32, day: u32) -> Result<DateTime<Utc>, FocusError> {
475    match Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) {
476        LocalResult::Single(value) => Ok(value),
477        LocalResult::Ambiguous(_, _) | LocalResult::None => Err(FocusError::InvalidTimestamp),
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use chrono::LocalResult;
485
486    fn timestamp() -> DateTime<Utc> {
487        match Utc.with_ymd_and_hms(2026, 1, 15, 12, 34, 56) {
488            LocalResult::Single(value) => value,
489            LocalResult::Ambiguous(_, _) | LocalResult::None => {
490                panic!("test timestamp should be valid")
491            }
492        }
493    }
494
495    fn record() -> FocusRecord {
496        let input = UnpricedUsage {
497            timestamp: timestamp(),
498            tool: "codex".to_string(),
499            model: "example-model".to_string(),
500            token_type: TokenType::Input,
501            token_count: 1_500,
502            project: Some("/work/project".to_string()),
503            access_path: FocusAccessPath::Subscription,
504            service_name: "Codex".to_string(),
505            service_provider_name: "OpenAI".to_string(),
506            host_provider_name: "OpenAI".to_string(),
507            invoice_issuer_name: "OpenAI".to_string(),
508            billing_currency: DEFAULT_BILLING_CURRENCY.to_string(),
509        };
510        match FocusRecord::unpriced_usage(input) {
511            Ok(value) => value,
512            Err(err) => panic!("record should build: {err}"),
513        }
514    }
515
516    #[test]
517    fn export_envelope_uses_canonical_focus_version() {
518        let envelope = FocusExportEnvelope::<()>::new(Vec::new());
519
520        assert_eq!(envelope.focus_version, FOCUS_VERSION);
521        assert!(envelope.rows.is_empty());
522    }
523
524    #[test]
525    fn unpriced_usage_has_required_cost_and_pricing_markers() {
526        let record = record();
527
528        assert_eq!(record.billed_cost, Decimal::from(0));
529        assert_eq!(record.effective_cost, Decimal::from(0));
530        assert_eq!(record.list_cost, Decimal::from(0));
531        assert_eq!(record.contracted_cost, Decimal::from(0));
532        // No priced SKU: FOCUS 1.3 requires these null when SkuPriceId is null.
533        assert_eq!(record.sku_price_id, None);
534        assert_eq!(record.pricing_category, None);
535        assert_eq!(record.pricing_quantity, None);
536        assert_eq!(record.pricing_unit, None);
537        assert_eq!(record.consumed_quantity, None);
538        assert_eq!(record.list_unit_price, None);
539        assert_eq!(record.contracted_unit_price, None);
540        assert_eq!(record.pricing_currency_list_unit_price, None);
541        // The raw token count still travels for the aggregation engine.
542        assert_eq!(record.x_consumed_tokens, Decimal::from(1_500));
543        assert_eq!(record.x_pricing_status, PRICING_STATUS_MISSING_PRICE);
544    }
545
546    #[test]
547    fn unpriced_usage_populates_mandatory_focus_columns() {
548        let record = record();
549
550        // Billing identity has no honest local value: documented placeholders.
551        assert_eq!(record.billing_account_id, BILLING_ACCOUNT_ID_LOCAL);
552        assert_eq!(record.billing_account_name, BILLING_ACCOUNT_NAME_LOCAL);
553        assert_eq!(
554            record.billing_account_type.as_deref(),
555            Some(BILLING_ACCOUNT_TYPE_LOCAL)
556        );
557        // Deprecated participating-entity columns mirror the active ones.
558        assert_eq!(record.provider_name, "OpenAI");
559        assert_eq!(record.publisher_name, "OpenAI");
560        // Correct categorical classification (not a deviation).
561        assert_eq!(
562            record.service_subcategory.as_deref(),
563            Some(SERVICE_SUBCATEGORY_GENERATIVE_AI)
564        );
565        // SkuMeter accompanies the SkuId; pricing currency mirrors billing currency.
566        assert_eq!(record.sku_meter.as_deref(), Some("input"));
567        assert_eq!(record.pricing_currency, DEFAULT_BILLING_CURRENCY);
568        assert_eq!(record.pricing_currency_effective_cost, Decimal::from(0));
569        // Columns with no local value stay null.
570        assert_eq!(record.region_id, None);
571        assert_eq!(record.commitment_discount_id, None);
572        assert_eq!(record.tags, None);
573    }
574
575    #[test]
576    fn unpriced_usage_maps_time_columns() {
577        let record = record();
578
579        assert_eq!(record.charge_period_start, timestamp());
580        assert_eq!(record.charge_period_end, timestamp() + Duration::seconds(1));
581        assert_eq!(
582            record.billing_period_start.to_rfc3339(),
583            "2026-01-01T00:00:00+00:00"
584        );
585        assert_eq!(
586            record.billing_period_end.to_rfc3339(),
587            "2026-02-01T00:00:00+00:00"
588        );
589    }
590
591    #[test]
592    fn charge_period_start_is_truncated_to_whole_seconds() {
593        let mut input = UnpricedUsage {
594            timestamp: timestamp(),
595            tool: "codex".to_string(),
596            model: "m".to_string(),
597            token_type: TokenType::Input,
598            token_count: 10,
599            project: None,
600            access_path: FocusAccessPath::Api,
601            service_name: "s".to_string(),
602            service_provider_name: "p".to_string(),
603            host_provider_name: "p".to_string(),
604            invoice_issuer_name: "p".to_string(),
605            billing_currency: DEFAULT_BILLING_CURRENCY.to_string(),
606        };
607        input.timestamp = match timestamp().with_nanosecond(123_456_789) {
608            Some(value) => value,
609            None => panic!("nanosecond should be valid"),
610        };
611
612        let record = match FocusRecord::unpriced_usage(input) {
613            Ok(value) => value,
614            Err(err) => panic!("record should build: {err}"),
615        };
616
617        assert_eq!(record.charge_period_start.nanosecond(), 0);
618        assert_eq!(
619            record.charge_period_end,
620            record.charge_period_start + Duration::seconds(1)
621        );
622    }
623
624    #[test]
625    fn json_export_emits_numbers_not_quoted_decimals() {
626        let json = match to_json_string(vec![record()]) {
627            Ok(value) => value,
628            Err(err) => panic!("json should serialize: {err}"),
629        };
630        let value: serde_json::Value = match serde_json::from_str(&json) {
631            Ok(value) => value,
632            Err(err) => panic!("json should parse: {err}"),
633        };
634
635        assert_eq!(value["focusVersion"], FOCUS_VERSION);
636        assert!(value["rows"].is_array());
637        let row = &value["rows"][0];
638        // Cost columns are JSON numbers, not quoted strings.
639        assert!(row["BilledCost"].is_number(), "BilledCost must be a number");
640        // Unpriced row: pricing/consumed quantity columns and unit price are null.
641        assert!(row["ListUnitPrice"].is_null());
642        assert!(row["PricingQuantity"].is_null());
643        assert!(row["ConsumedQuantity"].is_null());
644        assert!(row["PricingUnit"].is_null());
645        assert!(row["PricingCategory"].is_null());
646        // The raw token count is carried as a number on x_ConsumedTokens (1500).
647        assert!(
648            row["x_ConsumedTokens"].is_number(),
649            "x_ConsumedTokens must be a number"
650        );
651        assert_eq!(row["x_ConsumedTokens"].as_f64(), Some(1500.0));
652    }
653
654    #[test]
655    fn csv_export_renders_numerics_with_decimal_point() {
656        let csv = match to_csv_string(&[record()]) {
657            Ok(value) => value,
658            Err(err) => panic!("csv should serialize: {err}"),
659        };
660        let header = match csv.lines().next() {
661            Some(value) => value,
662            None => panic!("csv should have a header"),
663        };
664        let data = match csv.lines().nth(1) {
665            Some(value) => value,
666            None => panic!("csv should have a data row"),
667        };
668        let columns: Vec<&str> = header.split(',').collect();
669        let values: Vec<&str> = data.split(',').collect();
670        let field = |name: &str| -> &str {
671            match columns.iter().position(|c| *c == name) {
672                Some(index) => values[index],
673                None => panic!("column {name} should exist"),
674            }
675        };
676
677        // Whole-valued numeric columns still carry a decimal point so the
678        // validator infers a decimal/float type, not integer.
679        assert_eq!(field("BilledCost"), "0.0");
680        assert_eq!(field("x_ConsumedTokens"), "1500.0");
681        // Null option columns are empty fields (unpriced row).
682        assert_eq!(field("ListUnitPrice"), "");
683        assert_eq!(field("ConsumedQuantity"), "");
684        assert_eq!(field("PricingQuantity"), "");
685        assert_eq!(field("PricingUnit"), "");
686        assert_eq!(field("PricingCategory"), "");
687    }
688
689    #[test]
690    fn priced_shape_serializes_token_unit_and_per_token_price() {
691        // Simulate the shape `apply_pricing` (costroid-core) produces: token-count
692        // PricingQuantity, "tokens" unit, and a tiny per-token unit price.
693        let mut record = record();
694        record.pricing_unit = Some(PRICING_UNIT_TOKENS.to_string());
695        record.pricing_category = Some(PRICING_CATEGORY_STANDARD.to_string());
696        record.pricing_quantity = Some(Decimal::from(1_500));
697        record.consumed_quantity = Some(Decimal::from(1_500));
698        // 0.30 per 1M tokens -> 0.0000003 per token (a tiny decimal).
699        record.list_unit_price = Some(Decimal::new(3, 7));
700
701        let csv = match to_csv_string(&[record]) {
702            Ok(value) => value,
703            Err(err) => panic!("csv should serialize: {err}"),
704        };
705        let header = match csv.lines().next() {
706            Some(value) => value,
707            None => panic!("csv should have a header"),
708        };
709        let data = match csv.lines().nth(1) {
710            Some(value) => value,
711            None => panic!("csv should have a data row"),
712        };
713        let columns: Vec<&str> = header.split(',').collect();
714        let values: Vec<&str> = data.split(',').collect();
715        let field = |name: &str| -> &str {
716            match columns.iter().position(|c| *c == name) {
717                Some(index) => values[index],
718                None => panic!("column {name} should exist"),
719            }
720        };
721
722        assert_eq!(field("PricingUnit"), "tokens");
723        assert_eq!(field("PricingCategory"), "Standard");
724        // Token-count quantities serialize with a decimal point (validator type check).
725        assert_eq!(field("PricingQuantity"), "1500.0");
726        assert_eq!(field("ConsumedQuantity"), "1500.0");
727        // Tiny per-token price renders plainly (no scientific notation).
728        assert_eq!(field("ListUnitPrice"), "0.0000003");
729    }
730
731    #[test]
732    fn csv_header_carries_full_focus_column_set_then_custom_columns() {
733        let csv = match to_csv_string(&[record()]) {
734            Ok(value) => value,
735            Err(err) => panic!("csv should serialize: {err}"),
736        };
737        let header = match csv.lines().next() {
738            Some(value) => value,
739            None => panic!("csv should have a header"),
740        };
741        let fields: Vec<&str> = header.split(',').collect();
742
743        assert!(header.starts_with("BilledCost,EffectiveCost,ListCost,ContractedCost"));
744        for required in [
745            "BillingAccountId",
746            "BillingAccountName",
747            "BillingAccountType",
748            "ProviderName",
749            "PublisherName",
750            "ServiceSubcategory",
751            "SkuMeter",
752            "PricingCurrency",
753        ] {
754            assert!(fields.contains(&required), "missing column {required}");
755        }
756        assert!(header.ends_with(
757            "x_Model,x_TokenType,x_AccessPath,x_Estimated,x_Tool,x_Project,x_PricingStatus,x_ConsumedTokens"
758        ));
759    }
760}