1use chrono::{DateTime, Utc};
4use serde::Deserialize;
5
6use crate::error::{AppError, Result};
7use crate::usage::{ResetCredits, SuperGrokPeriod, SuperGrokSnapshot};
8
9const MAX_PLAN_CHARS: usize = 128;
10const MAX_BENIGN_PERCENT: f64 = 100.5;
11const MAX_EXACT_F64_INTEGER: i64 = 9_007_199_254_740_991;
12
13#[derive(Debug, Clone, Deserialize, Default)]
14#[serde(default)]
15pub struct BillingResponse {
16 pub config: Option<BillingConfig>,
17 #[serde(alias = "subscriptionTier")]
20 pub subscription_tier: Option<String>,
21 #[serde(skip)]
22 pub reset_credits: ResetCredits,
23}
24
25#[derive(Debug, Clone, Deserialize, Default)]
26#[serde(default, rename_all = "camelCase")]
27pub struct BillingConfig {
28 pub credit_usage_percent: Option<f64>,
29 pub current_period: Option<UsagePeriod>,
30 pub monthly_limit: Option<Cent>,
32 pub used: Option<Cent>,
33 pub on_demand_cap: Option<Cent>,
34 pub on_demand_used: Option<Cent>,
35 pub prepaid_balance: Option<Cent>,
36 pub is_unified_billing_user: Option<bool>,
37 pub billing_period_start: Option<String>,
38 pub billing_period_end: Option<String>,
39}
40
41#[derive(Debug, Clone, Deserialize, Default)]
42#[serde(default, rename_all = "camelCase")]
43pub struct UsagePeriod {
44 #[serde(rename = "type")]
45 pub period_type: Option<String>,
46 pub start: Option<String>,
47 pub end: Option<String>,
48}
49
50#[derive(Debug, Clone, Deserialize, Default)]
51pub struct Cent {
52 #[serde(default, deserialize_with = "de_cent_val")]
56 pub val: i64,
57}
58
59fn de_cent_val<'de, D>(deserializer: D) -> std::result::Result<i64, D::Error>
60where
61 D: serde::Deserializer<'de>,
62{
63 let value = serde_json::Value::deserialize(deserializer)?;
64 match value {
65 serde_json::Value::Number(number) => number
66 .as_i64()
67 .ok_or_else(|| serde::de::Error::custom("cent val must be an exact i64 integer")),
68 serde_json::Value::String(text) => text
69 .trim()
70 .parse::<i64>()
71 .map_err(|_| serde::de::Error::custom("cent val string must be an exact i64 integer")),
72 _ => Err(serde::de::Error::custom(
73 "cent val must be an integer number or string",
74 )),
75 }
76}
77
78pub fn to_snapshot(resp: BillingResponse, account_scope: &str) -> Result<SuperGrokSnapshot> {
79 let plan = checked_plan(resp.subscription_tier.as_deref())?;
80 let cfg = resp
81 .config
82 .ok_or_else(|| AppError::Schema("Grok Build billing response has no config".into()))?;
83 let period = resolve_period(&cfg);
84 let weekly_pct = resolve_usage_percent(&cfg)?;
85 let reset_at = resolve_reset_at(&cfg)?;
86 let prepaid_balance = cfg
87 .prepaid_balance
88 .map(|cents| checked_prepaid(cents.val))
89 .transpose()?;
90
91 Ok(SuperGrokSnapshot {
92 plan,
93 account: account_scope.to_string(),
94 weekly_pct,
95 period,
96 reset_at,
97 prepaid_balance,
98 reset_credits: resp.reset_credits,
99 })
100}
101
102fn checked_plan(value: Option<&str>) -> Result<String> {
103 let value = value.map(str::trim).filter(|s| !s.is_empty());
104 let Some(value) = value else {
105 return Ok("SuperGrok".into());
106 };
107 if value.chars().count() > MAX_PLAN_CHARS || value.chars().any(char::is_control) {
108 return Err(AppError::Schema(
109 "Grok Build subscription tier is invalid".into(),
110 ));
111 }
112 Ok(value.to_string())
113}
114
115fn resolve_usage_percent(cfg: &BillingConfig) -> Result<i32> {
116 if let Some(percent) = cfg.credit_usage_percent {
117 return checked_percent(percent);
118 }
119
120 if cfg.current_period.is_some() {
124 return Ok(0);
125 }
126
127 match (&cfg.used, &cfg.monthly_limit) {
128 (Some(used), Some(limit)) if limit.val > 0 && used.val >= 0 => {
129 checked_percent((used.val as f64 / limit.val as f64) * 100.0)
130 }
131 (Some(_), Some(_)) => Err(AppError::Schema(
132 "Grok Build legacy billing counters are negative or have a non-positive limit".into(),
133 )),
134 _ if cfg.billing_period_end.is_some()
135 || cfg.prepaid_balance.is_some()
136 || cfg.is_unified_billing_user.is_some() =>
137 {
138 Ok(0)
139 }
140 _ => Err(AppError::Schema(
141 "Grok Build billing response has no usage percentage or coherent legacy counters"
142 .into(),
143 )),
144 }
145}
146
147fn checked_percent(value: f64) -> Result<i32> {
148 if !value.is_finite() || !(0.0..=MAX_BENIGN_PERCENT).contains(&value) {
149 return Err(AppError::Schema(
150 "Grok Build billing percentage is outside the supported range".into(),
151 ));
152 }
153 Ok(value.round().clamp(0.0, 100.0) as i32)
154}
155
156fn resolve_period(cfg: &BillingConfig) -> SuperGrokPeriod {
157 let raw = cfg
158 .current_period
159 .as_ref()
160 .and_then(|period| period.period_type.as_deref())
161 .unwrap_or_default();
162 if raw.ends_with("WEEKLY") {
163 SuperGrokPeriod::Weekly
164 } else if raw.ends_with("MONTHLY")
165 || (cfg.current_period.is_none()
166 && (cfg.monthly_limit.is_some()
167 || cfg.used.is_some()
168 || cfg.billing_period_end.is_some()))
169 {
170 SuperGrokPeriod::Monthly
171 } else {
172 SuperGrokPeriod::Unknown
173 }
174}
175
176fn resolve_reset_at(cfg: &BillingConfig) -> Result<Option<DateTime<Utc>>> {
177 if let Some(period) = cfg.current_period.as_ref() {
178 return parse_optional_datetime(period.end.as_deref(), "currentPeriod.end");
179 }
180 parse_optional_datetime(cfg.billing_period_end.as_deref(), "billingPeriodEnd")
181}
182
183fn parse_optional_datetime(value: Option<&str>, field: &str) -> Result<Option<DateTime<Utc>>> {
184 let Some(value) = value else {
185 return Ok(None);
186 };
187 DateTime::parse_from_rfc3339(value)
188 .map(|dt| Some(dt.with_timezone(&Utc)))
189 .map_err(|_| AppError::Schema(format!("Grok Build {field} is not RFC 3339")))
190}
191
192fn checked_prepaid(cents: i64) -> Result<f64> {
193 if !(0..=MAX_EXACT_F64_INTEGER).contains(¢s) {
194 return Err(AppError::Schema(
195 "Grok Build prepaid balance is negative or too large to represent exactly".into(),
196 ));
197 }
198 Ok(cents as f64 / 100.0)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn weekly_acp_shape_is_coherent() {
207 let response: BillingResponse = serde_json::from_str(
208 r#"{
209 "config": {
210 "creditUsagePercent": 42.5,
211 "currentPeriod": {
212 "type": "USAGE_PERIOD_TYPE_WEEKLY",
213 "end": "2026-08-10T00:00:00Z"
214 },
215 "prepaidBalance": {"val": 1250}
216 },
217 "subscription_tier": "SuperGrok Heavy"
218 }"#,
219 )
220 .unwrap();
221 let snapshot = to_snapshot(response, "opaque-scope").unwrap();
222 assert_eq!(snapshot.weekly_pct, 43);
223 assert_eq!(snapshot.period, SuperGrokPeriod::Weekly);
224 assert_eq!(snapshot.plan, "SuperGrok Heavy");
225 assert_eq!(snapshot.prepaid_balance, Some(12.5));
226 }
227
228 #[test]
229 fn legacy_monthly_shape_keeps_its_own_reset() {
230 let response: BillingResponse = serde_json::from_str(
231 r#"{"config":{"monthlyLimit":{"val":"2000"},"used":{"val":500},"billingPeriodEnd":"2026-09-01T00:00:00Z"}}"#,
232 )
233 .unwrap();
234 let snapshot = to_snapshot(response, "scope").unwrap();
235 assert_eq!(snapshot.weekly_pct, 25);
236 assert_eq!(snapshot.period, SuperGrokPeriod::Monthly);
237 assert_eq!(
238 snapshot.reset_at.unwrap().to_rfc3339(),
239 "2026-09-01T00:00:00+00:00"
240 );
241 }
242
243 #[test]
244 fn omitted_zero_percent_does_not_import_legacy_monthly_usage() {
245 let response: BillingResponse = serde_json::from_str(
246 r#"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","end":"2026-08-13T00:00:00Z"},"monthlyLimit":{"val":1000},"used":{"val":900}}}"#,
247 )
248 .unwrap();
249 let snapshot = to_snapshot(response, "scope").unwrap();
250 assert_eq!(snapshot.weekly_pct, 0);
251 assert_eq!(snapshot.period, SuperGrokPeriod::Weekly);
252 }
253
254 #[test]
255 fn cents_must_be_exact_integers() {
256 for value in ["1.5", "1e100", "null", "true"] {
257 let body = format!(r#"{{"config":{{"prepaidBalance":{{"val":{value}}}}}}}"#);
258 assert!(
259 serde_json::from_str::<BillingResponse>(&body).is_err(),
260 "{body}"
261 );
262 }
263 let omitted: BillingResponse =
264 serde_json::from_str(r#"{"config":{"prepaidBalance":{}}}"#).unwrap();
265 assert_eq!(omitted.config.unwrap().prepaid_balance.unwrap().val, 0);
266 }
267
268 #[test]
269 fn malformed_percentages_and_resets_are_rejected() {
270 for percent in [-1.0, 101.0, f64::INFINITY] {
271 assert!(checked_percent(percent).is_err());
272 }
273 let response: BillingResponse = serde_json::from_str(
274 r#"{"config":{"creditUsagePercent":5,"currentPeriod":{"end":"not-a-date"}}}"#,
275 )
276 .unwrap();
277 assert!(to_snapshot(response, "scope").is_err());
278 assert!(checked_prepaid(-1).is_err());
279 assert!(checked_prepaid(MAX_EXACT_F64_INTEGER + 1).is_err());
280 }
281
282 #[test]
283 fn plan_labels_are_bounded_and_control_free() {
284 assert!(checked_plan(Some(&"x".repeat(MAX_PLAN_CHARS + 1))).is_err());
285 assert!(checked_plan(Some("bad\u{1b}[31m")).is_err());
286 assert_eq!(checked_plan(Some(" ")).unwrap(), "SuperGrok");
287 }
288}