1use chrono::{DateTime, Utc};
10use serde_json::Value;
11
12#[derive(Debug, Clone, PartialEq)]
18pub struct SpendWindow {
19 pub used: f64,
20 pub cap: f64,
21 pub resets_at: Option<DateTime<Utc>>,
22}
23
24impl Eq for SpendWindow {}
25
26impl SpendWindow {
27 pub fn pct(&self) -> i32 {
29 if !self.cap.is_finite() || self.cap <= 0.0 {
32 return 0;
33 }
34 ((self.used / self.cap) * 100.0).round().clamp(0.0, 100.0) as i32
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Default)]
41pub struct Credits {
42 pub monthly: f64,
43 pub purchased: f64,
44 pub free: f64,
45}
46
47impl Eq for Credits {}
48
49impl Credits {
50 pub fn remaining(&self) -> f64 {
52 self.monthly + self.purchased + self.free
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Default)]
58pub struct Snapshot {
59 pub plan: Option<String>,
61 pub five_hour: Option<SpendWindow>,
62 pub weekly: Option<SpendWindow>,
63 pub credits: Option<Credits>,
64 pub credit_pool: Option<f64>,
66 pub period_end: Option<DateTime<Utc>>,
70}
71
72impl Eq for Snapshot {}
73
74impl Snapshot {
75 pub fn worst_pct(&self) -> i32 {
77 self.five_hour
78 .iter()
79 .chain(self.weekly.iter())
80 .chain(self.monthly_window().iter())
81 .map(SpendWindow::pct)
82 .max()
83 .unwrap_or(0)
84 }
85
86 pub fn credits_spent(&self) -> Option<f64> {
88 let pool = self.credit_pool?;
89 let remaining = self.credits.as_ref()?.remaining();
90 Some((pool - remaining).max(0.0))
91 }
92
93 pub fn monthly_window(&self) -> Option<SpendWindow> {
97 let spent = self.credits_spent()?;
98 Some(SpendWindow {
99 used: spent,
100 cap: self.credit_pool.unwrap_or_default(),
101 resets_at: self.period_end,
102 })
103 }
104}
105
106const PLAN_CREDITS: &[(&str, f64)] = &[
112 ("individual-go", 10.0),
113 ("individual-goat", 70.0),
114 ("individual-pro", 30.0),
115 ("individual-pro-v1", 80.0),
116 ("individual-provider", 15.0),
117 ("individual-max", 150.0),
118 ("individual-ultra", 300.0),
119 ("teams-pro", 40.0),
120];
121
122const PLAN_LABELS: &[(&str, &str)] = &[
124 ("individual-go", "Go"),
125 ("individual-goat", "GOAT"),
126 ("individual-pro", "Pro"),
127 ("individual-pro-v1", "Pro"),
128 ("individual-provider", "Provider"),
129 ("individual-max", "Max"),
130 ("individual-ultra", "Ultra"),
131 ("teams-pro", "Teams Pro"),
132];
133
134pub fn plan_label(plan_id: &str) -> String {
135 PLAN_LABELS
136 .iter()
137 .find(|(id, _)| *id == plan_id)
138 .map(|(_, label)| (*label).to_string())
139 .unwrap_or_else(|| plan_id.to_string())
140}
141
142pub fn plan_credits(plan_id: &str) -> Option<f64> {
143 PLAN_CREDITS
144 .iter()
145 .find(|(id, _)| *id == plan_id)
146 .map(|(_, credits)| *credits)
147}
148
149pub fn parse_credits(value: &Value) -> Result<Snapshot, String> {
151 let root = value
152 .as_object()
153 .ok_or_else(|| "Command Code credits response must be a JSON object".to_string())?;
154 if root.contains_key("error") {
155 return Err("Command Code credits response is an error envelope".to_string());
156 }
157
158 let ledger = root.get("credits").and_then(Value::as_object);
161 let windows = root
162 .get("windowLimits")
163 .and_then(Value::as_object)
164 .or_else(|| {
165 ledger
166 .and_then(|l| l.get("windowLimits"))
167 .and_then(Value::as_object)
168 });
169
170 let credits = ledger.map(|ledger| Credits {
171 monthly: finite(ledger.get("monthlyCredits")).unwrap_or(0.0),
172 purchased: finite(ledger.get("purchasedCredits")).unwrap_or(0.0),
173 free: finite(ledger.get("freeCredits")).unwrap_or(0.0),
174 });
175
176 let snapshot = Snapshot {
177 plan: None,
178 five_hour: windows
179 .and_then(|w| parse_window(w.get("fiveHour"), "fiveHour"))
180 .transpose()?,
181 weekly: windows
182 .and_then(|w| parse_window(w.get("weekly"), "weekly"))
183 .transpose()?,
184 credits,
185 credit_pool: None,
186 period_end: None,
187 };
188
189 if snapshot.five_hour.is_none() && snapshot.weekly.is_none() && snapshot.credits.is_none() {
190 return Err("Command Code credits response has no windows or ledger".to_string());
191 }
192 Ok(snapshot)
193}
194
195pub fn apply_subscription(snapshot: &mut Snapshot, value: &Value) {
198 let Some(plan_id) = value
199 .get("data")
200 .and_then(|data| data.get("planId"))
201 .and_then(Value::as_str)
202 .map(str::trim)
203 .filter(|id| !id.is_empty())
204 else {
205 return;
206 };
207 snapshot.plan = Some(plan_label(plan_id));
208 snapshot.credit_pool = plan_credits(plan_id);
209 snapshot.period_end = value
212 .get("data")
213 .and_then(|data| data.get("currentPeriodEnd"))
214 .and_then(Value::as_str)
215 .and_then(|text| text.trim().parse::<DateTime<chrono::FixedOffset>>().ok())
216 .map(|parsed| parsed.with_timezone(&Utc));
217}
218
219fn parse_window(value: Option<&Value>, name: &str) -> Option<Result<SpendWindow, String>> {
220 let value = value?;
221 if value.is_null() {
222 return None;
223 }
224 let Some(object) = value.as_object() else {
225 return Some(Err(format!("windowLimits.{name} must be an object")));
226 };
227 let (Some(used), Some(cap)) = (finite(object.get("used")), finite(object.get("cap"))) else {
228 return Some(Err(format!(
229 "windowLimits.{name} must carry finite used and cap"
230 )));
231 };
232 if used < 0.0 || cap < 0.0 {
233 return Some(Err(format!("windowLimits.{name} must not be negative")));
234 }
235 Some(Ok(SpendWindow {
236 used,
237 cap,
238 resets_at: object.get("resetAt").and_then(parse_reset),
239 }))
240}
241
242fn parse_reset(value: &Value) -> Option<DateTime<Utc>> {
246 if let Some(millis) = value.as_i64() {
247 return DateTime::from_timestamp_millis(millis);
248 }
249 if let Some(text) = value.as_str() {
250 if let Ok(parsed) = text.parse::<DateTime<chrono::FixedOffset>>() {
251 return Some(parsed.with_timezone(&Utc));
252 }
253 if let Ok(millis) = text.parse::<i64>() {
254 return DateTime::from_timestamp_millis(millis);
255 }
256 }
257 None
258}
259
260fn finite(value: Option<&Value>) -> Option<f64> {
261 value.and_then(Value::as_f64).filter(|n| n.is_finite())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn credits_value() -> Value {
269 serde_json::from_str(include_str!(
270 "../../tests/fixtures/commandcode/credits.json"
271 ))
272 .expect("fixture JSON must be valid")
273 }
274
275 #[test]
276 fn parses_the_live_credits_fixture() {
277 let snapshot = parse_credits(&credits_value()).expect("fixture must parse");
278
279 let five_hour = snapshot.five_hour.expect("fiveHour window");
280 assert_eq!(five_hour.cap, 14.0);
281 assert_eq!(five_hour.pct(), 25);
282 assert_eq!(
283 five_hour.resets_at.expect("reset").to_rfc3339(),
284 "2026-08-27T20:00:00+00:00"
285 );
286
287 let weekly = snapshot.weekly.expect("weekly window");
288 assert_eq!(weekly.cap, 35.0);
289 assert_eq!(weekly.pct(), 30);
290
291 let credits = snapshot.credits.expect("ledger");
292 assert_eq!(credits.remaining(), 42.0);
293 }
294
295 #[test]
296 fn millisecond_epoch_resets_become_utc_timestamps() {
297 let value = serde_json::json!({
300 "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": 1788374172830_i64}}
301 });
302
303 let weekly = parse_credits(&value).unwrap().weekly.expect("weekly");
304
305 assert_eq!(
306 weekly.resets_at.expect("reset").to_rfc3339(),
307 "2026-09-02T18:36:12.830+00:00"
308 );
309 }
310
311 #[test]
312 fn accepts_windows_nested_beside_the_ledger() {
313 let value = serde_json::json!({
314 "credits": {
315 "monthlyCredits": 5.0,
316 "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": null}}
317 }
318 });
319
320 let snapshot = parse_credits(&value).expect("nested windows must parse");
321
322 assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
323 assert_eq!(snapshot.credits.expect("ledger").remaining(), 5.0);
324 }
325
326 #[test]
327 fn sums_every_credit_pool() {
328 let value = serde_json::json!({
329 "credits": {"monthlyCredits": 10.5, "purchasedCredits": 4.0, "freeCredits": 0.5},
330 "windowLimits": {"weekly": {"used": 1, "cap": 4}}
331 });
332
333 let credits = parse_credits(&value).unwrap().credits.expect("ledger");
334
335 assert_eq!(credits.remaining(), 15.0);
336 }
337
338 #[test]
339 fn rejects_error_envelopes_and_empty_documents() {
340 for value in [
341 serde_json::json!({}),
342 serde_json::json!({"error": "unauthorized"}),
343 serde_json::json!({"windowLimits": {}}),
344 ] {
345 assert!(parse_credits(&value).is_err(), "accepted {value}");
346 }
347 assert!(parse_credits(&serde_json::json!("nope")).is_err());
348 }
349
350 #[test]
351 fn rejects_malformed_or_negative_windows() {
352 for window in [
353 serde_json::json!("not-an-object"),
354 serde_json::json!({"used": -1, "cap": 4}),
355 serde_json::json!({"used": 1}),
356 serde_json::json!({"used": "1", "cap": 4}),
357 ] {
358 let value = serde_json::json!({"windowLimits": {"weekly": window}});
359 assert!(parse_credits(&value).is_err(), "accepted {window}");
360 }
361 }
362
363 #[test]
364 fn additive_fields_and_null_windows_are_tolerated() {
365 let value = serde_json::json!({
368 "credits": {"monthlyCredits": 1.0, "unexpected": true},
369 "windowLimits": {
370 "limited": true,
371 "exceeded": null,
372 "fiveHour": null,
373 "weekly": {"used": 1, "cap": 4, "exceeded": false, "future": 1}
374 }
375 });
376
377 let snapshot = parse_credits(&value).expect("must tolerate additive fields");
378
379 assert!(snapshot.five_hour.is_none());
380 assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
381 }
382
383 #[test]
384 fn percentage_is_clamped_and_safe_at_a_zero_cap() {
385 assert_eq!(
386 SpendWindow {
387 used: 9.0,
388 cap: 0.0,
389 resets_at: None
390 }
391 .pct(),
392 0
393 );
394 assert_eq!(
395 SpendWindow {
396 used: 9.0,
397 cap: 4.0,
398 resets_at: None
399 }
400 .pct(),
401 100
402 );
403 }
404
405 #[test]
406 fn subscription_supplies_the_plan_label_and_its_allowance() {
407 let subscription: Value = serde_json::from_str(include_str!(
408 "../../tests/fixtures/commandcode/subscriptions.json"
409 ))
410 .expect("fixture JSON must be valid");
411 let mut snapshot = parse_credits(&credits_value()).unwrap();
412
413 apply_subscription(&mut snapshot, &subscription);
414
415 assert_eq!(snapshot.plan.as_deref(), Some("GOAT"));
416 assert_eq!(snapshot.credit_pool, Some(70.0));
417 assert_eq!(snapshot.credits_spent(), Some(28.0));
419 assert_eq!(
421 snapshot.period_end.expect("period end").to_rfc3339(),
422 "2026-09-19T16:39:05+00:00"
423 );
424 }
425
426 #[test]
427 fn a_malformed_period_end_is_dropped_not_fatal() {
428 let mut snapshot = parse_credits(&credits_value()).unwrap();
429
430 apply_subscription(
431 &mut snapshot,
432 &serde_json::json!({"data": {"planId": "individual-goat", "currentPeriodEnd": "not-a-date"}}),
433 );
434
435 assert_eq!(snapshot.plan.as_deref(), Some("GOAT"));
436 assert_eq!(snapshot.period_end, None);
437 }
438
439 #[test]
440 fn unknown_plan_keeps_its_id_and_claims_no_allowance() {
441 let mut snapshot = parse_credits(&credits_value()).unwrap();
442
443 apply_subscription(
444 &mut snapshot,
445 &serde_json::json!({"data": {"planId": "individual-future"}}),
446 );
447
448 assert_eq!(snapshot.plan.as_deref(), Some("individual-future"));
449 assert_eq!(snapshot.credit_pool, None);
450 assert_eq!(snapshot.credits_spent(), None);
451 }
452
453 #[test]
454 fn missing_subscription_leaves_the_snapshot_untouched() {
455 let mut snapshot = parse_credits(&credits_value()).unwrap();
456
457 apply_subscription(&mut snapshot, &serde_json::json!({"success": false}));
458
459 assert!(snapshot.plan.is_none());
460 assert!(snapshot.weekly.is_some());
461 }
462
463 #[test]
464 fn worst_window_leads_the_bar_text() {
465 let snapshot = Snapshot {
466 five_hour: Some(SpendWindow {
467 used: 1.0,
468 cap: 10.0,
469 resets_at: None,
470 }),
471 weekly: Some(SpendWindow {
472 used: 8.0,
473 cap: 10.0,
474 resets_at: None,
475 }),
476 ..Snapshot::default()
477 };
478
479 assert_eq!(snapshot.worst_pct(), 80);
480 assert_eq!(Snapshot::default().worst_pct(), 0);
481 }
482}