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}
67
68impl Eq for Snapshot {}
69
70impl Snapshot {
71 pub fn worst_pct(&self) -> i32 {
73 self.five_hour
74 .iter()
75 .chain(self.weekly.iter())
76 .map(SpendWindow::pct)
77 .max()
78 .unwrap_or(0)
79 }
80
81 pub fn credits_spent(&self) -> Option<f64> {
83 let pool = self.credit_pool?;
84 let remaining = self.credits.as_ref()?.remaining();
85 Some((pool - remaining).max(0.0))
86 }
87}
88
89const PLAN_CREDITS: &[(&str, f64)] = &[
95 ("individual-go", 10.0),
96 ("individual-goat", 70.0),
97 ("individual-pro", 30.0),
98 ("individual-pro-v1", 80.0),
99 ("individual-provider", 15.0),
100 ("individual-max", 150.0),
101 ("individual-ultra", 300.0),
102 ("teams-pro", 40.0),
103];
104
105const PLAN_LABELS: &[(&str, &str)] = &[
107 ("individual-go", "Go"),
108 ("individual-goat", "GOAT"),
109 ("individual-pro", "Pro"),
110 ("individual-pro-v1", "Pro"),
111 ("individual-provider", "Provider"),
112 ("individual-max", "Max"),
113 ("individual-ultra", "Ultra"),
114 ("teams-pro", "Teams Pro"),
115];
116
117pub fn plan_label(plan_id: &str) -> String {
118 PLAN_LABELS
119 .iter()
120 .find(|(id, _)| *id == plan_id)
121 .map(|(_, label)| (*label).to_string())
122 .unwrap_or_else(|| plan_id.to_string())
123}
124
125pub fn plan_credits(plan_id: &str) -> Option<f64> {
126 PLAN_CREDITS
127 .iter()
128 .find(|(id, _)| *id == plan_id)
129 .map(|(_, credits)| *credits)
130}
131
132pub fn parse_credits(value: &Value) -> Result<Snapshot, String> {
134 let root = value
135 .as_object()
136 .ok_or_else(|| "Command Code credits response must be a JSON object".to_string())?;
137 if root.contains_key("error") {
138 return Err("Command Code credits response is an error envelope".to_string());
139 }
140
141 let ledger = root.get("credits").and_then(Value::as_object);
144 let windows = root
145 .get("windowLimits")
146 .and_then(Value::as_object)
147 .or_else(|| {
148 ledger
149 .and_then(|l| l.get("windowLimits"))
150 .and_then(Value::as_object)
151 });
152
153 let credits = ledger.map(|ledger| Credits {
154 monthly: finite(ledger.get("monthlyCredits")).unwrap_or(0.0),
155 purchased: finite(ledger.get("purchasedCredits")).unwrap_or(0.0),
156 free: finite(ledger.get("freeCredits")).unwrap_or(0.0),
157 });
158
159 let snapshot = Snapshot {
160 plan: None,
161 five_hour: windows
162 .and_then(|w| parse_window(w.get("fiveHour"), "fiveHour"))
163 .transpose()?,
164 weekly: windows
165 .and_then(|w| parse_window(w.get("weekly"), "weekly"))
166 .transpose()?,
167 credits,
168 credit_pool: None,
169 };
170
171 if snapshot.five_hour.is_none() && snapshot.weekly.is_none() && snapshot.credits.is_none() {
172 return Err("Command Code credits response has no windows or ledger".to_string());
173 }
174 Ok(snapshot)
175}
176
177pub fn apply_subscription(snapshot: &mut Snapshot, value: &Value) {
180 let Some(plan_id) = value
181 .get("data")
182 .and_then(|data| data.get("planId"))
183 .and_then(Value::as_str)
184 .map(str::trim)
185 .filter(|id| !id.is_empty())
186 else {
187 return;
188 };
189 snapshot.plan = Some(plan_label(plan_id));
190 snapshot.credit_pool = plan_credits(plan_id);
191}
192
193fn parse_window(value: Option<&Value>, name: &str) -> Option<Result<SpendWindow, String>> {
194 let value = value?;
195 if value.is_null() {
196 return None;
197 }
198 let Some(object) = value.as_object() else {
199 return Some(Err(format!("windowLimits.{name} must be an object")));
200 };
201 let (Some(used), Some(cap)) = (finite(object.get("used")), finite(object.get("cap"))) else {
202 return Some(Err(format!(
203 "windowLimits.{name} must carry finite used and cap"
204 )));
205 };
206 if used < 0.0 || cap < 0.0 {
207 return Some(Err(format!("windowLimits.{name} must not be negative")));
208 }
209 Some(Ok(SpendWindow {
210 used,
211 cap,
212 resets_at: object.get("resetAt").and_then(parse_reset),
213 }))
214}
215
216fn parse_reset(value: &Value) -> Option<DateTime<Utc>> {
220 if let Some(millis) = value.as_i64() {
221 return DateTime::from_timestamp_millis(millis);
222 }
223 if let Some(text) = value.as_str() {
224 if let Ok(parsed) = text.parse::<DateTime<chrono::FixedOffset>>() {
225 return Some(parsed.with_timezone(&Utc));
226 }
227 if let Ok(millis) = text.parse::<i64>() {
228 return DateTime::from_timestamp_millis(millis);
229 }
230 }
231 None
232}
233
234fn finite(value: Option<&Value>) -> Option<f64> {
235 value.and_then(Value::as_f64).filter(|n| n.is_finite())
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn credits_value() -> Value {
243 serde_json::from_str(include_str!(
244 "../../tests/fixtures/commandcode/credits.json"
245 ))
246 .expect("fixture JSON must be valid")
247 }
248
249 #[test]
250 fn parses_the_live_credits_fixture() {
251 let snapshot = parse_credits(&credits_value()).expect("fixture must parse");
252
253 let five_hour = snapshot.five_hour.expect("fiveHour window");
254 assert_eq!(five_hour.cap, 14.0);
255 assert_eq!(five_hour.pct(), 25);
256 assert_eq!(
257 five_hour.resets_at.expect("reset").to_rfc3339(),
258 "2026-08-27T20:00:00+00:00"
259 );
260
261 let weekly = snapshot.weekly.expect("weekly window");
262 assert_eq!(weekly.cap, 35.0);
263 assert_eq!(weekly.pct(), 30);
264
265 let credits = snapshot.credits.expect("ledger");
266 assert_eq!(credits.remaining(), 42.0);
267 }
268
269 #[test]
270 fn millisecond_epoch_resets_become_utc_timestamps() {
271 let value = serde_json::json!({
274 "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": 1788374172830_i64}}
275 });
276
277 let weekly = parse_credits(&value).unwrap().weekly.expect("weekly");
278
279 assert_eq!(
280 weekly.resets_at.expect("reset").to_rfc3339(),
281 "2026-09-02T18:36:12.830+00:00"
282 );
283 }
284
285 #[test]
286 fn accepts_windows_nested_beside_the_ledger() {
287 let value = serde_json::json!({
288 "credits": {
289 "monthlyCredits": 5.0,
290 "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": null}}
291 }
292 });
293
294 let snapshot = parse_credits(&value).expect("nested windows must parse");
295
296 assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
297 assert_eq!(snapshot.credits.expect("ledger").remaining(), 5.0);
298 }
299
300 #[test]
301 fn sums_every_credit_pool() {
302 let value = serde_json::json!({
303 "credits": {"monthlyCredits": 10.5, "purchasedCredits": 4.0, "freeCredits": 0.5},
304 "windowLimits": {"weekly": {"used": 1, "cap": 4}}
305 });
306
307 let credits = parse_credits(&value).unwrap().credits.expect("ledger");
308
309 assert_eq!(credits.remaining(), 15.0);
310 }
311
312 #[test]
313 fn rejects_error_envelopes_and_empty_documents() {
314 for value in [
315 serde_json::json!({}),
316 serde_json::json!({"error": "unauthorized"}),
317 serde_json::json!({"windowLimits": {}}),
318 ] {
319 assert!(parse_credits(&value).is_err(), "accepted {value}");
320 }
321 assert!(parse_credits(&serde_json::json!("nope")).is_err());
322 }
323
324 #[test]
325 fn rejects_malformed_or_negative_windows() {
326 for window in [
327 serde_json::json!("not-an-object"),
328 serde_json::json!({"used": -1, "cap": 4}),
329 serde_json::json!({"used": 1}),
330 serde_json::json!({"used": "1", "cap": 4}),
331 ] {
332 let value = serde_json::json!({"windowLimits": {"weekly": window}});
333 assert!(parse_credits(&value).is_err(), "accepted {window}");
334 }
335 }
336
337 #[test]
338 fn additive_fields_and_null_windows_are_tolerated() {
339 let value = serde_json::json!({
342 "credits": {"monthlyCredits": 1.0, "unexpected": true},
343 "windowLimits": {
344 "limited": true,
345 "exceeded": null,
346 "fiveHour": null,
347 "weekly": {"used": 1, "cap": 4, "exceeded": false, "future": 1}
348 }
349 });
350
351 let snapshot = parse_credits(&value).expect("must tolerate additive fields");
352
353 assert!(snapshot.five_hour.is_none());
354 assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
355 }
356
357 #[test]
358 fn percentage_is_clamped_and_safe_at_a_zero_cap() {
359 assert_eq!(
360 SpendWindow {
361 used: 9.0,
362 cap: 0.0,
363 resets_at: None
364 }
365 .pct(),
366 0
367 );
368 assert_eq!(
369 SpendWindow {
370 used: 9.0,
371 cap: 4.0,
372 resets_at: None
373 }
374 .pct(),
375 100
376 );
377 }
378
379 #[test]
380 fn subscription_supplies_the_plan_label_and_its_allowance() {
381 let subscription: Value = serde_json::from_str(include_str!(
382 "../../tests/fixtures/commandcode/subscriptions.json"
383 ))
384 .expect("fixture JSON must be valid");
385 let mut snapshot = parse_credits(&credits_value()).unwrap();
386
387 apply_subscription(&mut snapshot, &subscription);
388
389 assert_eq!(snapshot.plan.as_deref(), Some("GOAT"));
390 assert_eq!(snapshot.credit_pool, Some(70.0));
391 assert_eq!(snapshot.credits_spent(), Some(28.0));
393 }
394
395 #[test]
396 fn unknown_plan_keeps_its_id_and_claims_no_allowance() {
397 let mut snapshot = parse_credits(&credits_value()).unwrap();
398
399 apply_subscription(
400 &mut snapshot,
401 &serde_json::json!({"data": {"planId": "individual-future"}}),
402 );
403
404 assert_eq!(snapshot.plan.as_deref(), Some("individual-future"));
405 assert_eq!(snapshot.credit_pool, None);
406 assert_eq!(snapshot.credits_spent(), None);
407 }
408
409 #[test]
410 fn missing_subscription_leaves_the_snapshot_untouched() {
411 let mut snapshot = parse_credits(&credits_value()).unwrap();
412
413 apply_subscription(&mut snapshot, &serde_json::json!({"success": false}));
414
415 assert!(snapshot.plan.is_none());
416 assert!(snapshot.weekly.is_some());
417 }
418
419 #[test]
420 fn worst_window_leads_the_bar_text() {
421 let snapshot = Snapshot {
422 five_hour: Some(SpendWindow {
423 used: 1.0,
424 cap: 10.0,
425 resets_at: None,
426 }),
427 weekly: Some(SpendWindow {
428 used: 8.0,
429 cap: 10.0,
430 resets_at: None,
431 }),
432 ..Snapshot::default()
433 };
434
435 assert_eq!(snapshot.worst_pct(), 80);
436 assert_eq!(Snapshot::default().worst_pct(), 0);
437 }
438}