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