1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Deserializer, Serialize};
3use serde_json::Value;
4
5use crate::error::{AppError, Result};
6use crate::usage::{SyntheticSnapshot, UsageWindow, WeeklyCredits};
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Response {
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub plan_weekly_limit: Option<PlanWeeklyLimit>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub weekly_token_limit: Option<WeeklyTokenLimit>,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct PlanWeeklyLimit {
26 pub next_regen_at: String,
27 pub percent_remaining: f64,
28 pub next_regen_percent: f64,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct WeeklyTokenLimit {
34 pub next_regen_at: String,
35 pub percent_remaining: f64,
36 #[serde(deserialize_with = "de_credits")]
38 pub max_credits: f64,
39 #[serde(deserialize_with = "de_credits")]
40 pub remaining_credits: f64,
41 #[serde(deserialize_with = "de_credits")]
42 pub next_regen_credits: f64,
43}
44
45struct WeeklyFigures {
46 percent_remaining: f64,
47 renews_at: DateTime<Utc>,
48 next_regen_percent: Option<f64>,
49 credits: Option<WeeklyCredits>,
50}
51
52impl WeeklyFigures {
53 fn snapshot(self) -> SyntheticSnapshot {
54 let utilization_pct = (100.0 - self.percent_remaining).round() as i32;
55 let utilization_precise = ((100.0 - self.percent_remaining) * 100.0).round() / 100.0;
56 SyntheticSnapshot {
57 plan: None,
58 weekly: UsageWindow {
59 utilization_pct,
60 resets_at: Some(self.renews_at),
61 window_duration: chrono::Duration::zero(),
66 },
67 credits: self.credits,
68 next_regen_percent: self.next_regen_percent,
69 utilization_precise,
70 }
71 }
72}
73
74impl PlanWeeklyLimit {
75 fn figures(&self) -> Result<WeeklyFigures> {
76 Ok(WeeklyFigures {
77 percent_remaining: parse_percent(self.percent_remaining)?,
78 renews_at: parse_renews_at(&self.next_regen_at)?,
79 next_regen_percent: Some(parse_regens(self.next_regen_percent)?),
80 credits: None,
81 })
82 }
83}
84
85impl WeeklyTokenLimit {
86 fn figures(&self) -> Result<WeeklyFigures> {
87 let (max, remaining, next_regen) = (
88 self.max_credits,
89 self.remaining_credits,
90 self.next_regen_credits,
91 );
92 if !max.is_finite()
93 || max <= 0.0
94 || !remaining.is_finite()
95 || remaining < 0.0
96 || !next_regen.is_finite()
97 || next_regen < 0.0
98 {
99 return Err(AppError::Schema(
100 "Synthetic weekly credit budget contains an invalid amount".into(),
101 ));
102 }
103 Ok(WeeklyFigures {
104 percent_remaining: parse_percent(self.percent_remaining)?,
105 renews_at: parse_renews_at(&self.next_regen_at)?,
106 next_regen_percent: None,
107 credits: Some(WeeklyCredits {
108 used: (max - remaining).max(0.0),
109 max,
110 next_regen,
111 }),
112 })
113 }
114}
115
116impl Response {
117 pub fn snapshot(&self) -> Result<SyntheticSnapshot> {
118 let mut figures = match (&self.plan_weekly_limit, &self.weekly_token_limit) {
122 (Some(plan), _) => plan.figures()?,
123 (None, Some(tokens)) => tokens.figures()?,
124 (None, None) => {
125 return Err(AppError::Schema(
126 "Synthetic quota response carries no weekly limit".into(),
127 ));
128 }
129 };
130 if figures.credits.is_none()
131 && let Some(tokens) = &self.weekly_token_limit
132 && let Ok(extra) = tokens.figures()
133 {
134 figures.credits = extra.credits;
135 }
136 Ok(figures.snapshot())
137 }
138}
139
140fn parse_percent(value: f64) -> Result<f64> {
141 if !value.is_finite() || value < 0.0 {
142 return Err(AppError::Schema(
143 "Synthetic weekly quota contains an invalid remaining percentage".into(),
144 ));
145 }
146 Ok(value.clamp(0.0, 100.0))
147}
148
149fn parse_regens(value: f64) -> Result<f64> {
150 if !value.is_finite() || value < 0.0 {
151 return Err(AppError::Schema(
152 "Synthetic weekly quota contains an invalid regeneration amount".into(),
153 ));
154 }
155 Ok(value)
156}
157
158fn parse_renews_at(raw: &str) -> Result<DateTime<Utc>> {
159 DateTime::parse_from_rfc3339(raw)
160 .map(|dt| dt.with_timezone(&Utc))
161 .map_err(|_| AppError::Schema("Synthetic weekly quota renewal is invalid".into()))
162}
163
164fn de_credits<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
167where
168 D: Deserializer<'de>,
169{
170 use serde::de::Error as _;
171 match Value::deserialize(deserializer)? {
172 Value::Number(number) => number
173 .as_f64()
174 .ok_or_else(|| D::Error::custom("credit amount is not a finite number")),
175 Value::String(raw) => {
176 let cleaned = raw.trim().trim_start_matches('$').replace(',', "");
177 cleaned
178 .parse::<f64>()
179 .map_err(|_| D::Error::custom("credit amount is not a number"))
180 }
181 _ => Err(D::Error::custom(
182 "credit amount must be a number or numeric string",
183 )),
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 const PLAN: &str = r#"{"planWeeklyLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":68.97415291835814,"nextRegenPercent":2}}"#;
192 const TOKENS: &str = r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":82.4,"maxCredits":"30.00","remainingCredits":"24.72","nextRegenCredits":"0.60"}}"#;
193
194 #[test]
195 fn plan_shape_projects_the_documented_weekly_example() {
196 let response: Response = serde_json::from_str(PLAN).unwrap();
198 let snapshot = response.snapshot().unwrap();
199 assert_eq!(snapshot.weekly.utilization_pct, 31);
200 assert_eq!(snapshot.utilization_precise, 31.03);
201 assert_eq!(
202 snapshot.weekly.resets_at,
203 Some("2026-10-27T01:09:02.000Z".parse().unwrap())
204 );
205 assert_eq!(snapshot.weekly.window_duration, chrono::Duration::zero());
206 assert_eq!(snapshot.credits, None);
207 assert_eq!(snapshot.next_regen_percent, Some(2.0));
208 }
209
210 #[test]
211 fn token_shape_reports_the_credit_budget() {
212 let response: Response = serde_json::from_str(TOKENS).unwrap();
213 let snapshot = response.snapshot().unwrap();
214 assert_eq!(snapshot.weekly.utilization_pct, 18);
215 let credits = snapshot.credits.expect("credit figures");
216 assert!((credits.used - 5.28).abs() < 1e-9);
217 assert_eq!(credits.max, 30.0);
218 assert_eq!(credits.next_regen, 0.6);
219 }
220
221 #[test]
222 fn plan_shape_wins_but_adopts_token_credits() {
223 let response: Response = serde_json::from_str(
224 r#"{"planWeeklyLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":68.97415291835814,"nextRegenPercent":2},
225 "weeklyTokenLimit":{"nextRegenAt":"2026-10-27T05:00:00.000Z","percentRemaining":82.4,"maxCredits":"30.00","remainingCredits":"24.72","nextRegenCredits":"0.60"}}"#,
226 )
227 .unwrap();
228 let snapshot = response.snapshot().unwrap();
229 assert_eq!(snapshot.weekly.utilization_pct, 31);
231 assert_eq!(
232 snapshot.weekly.resets_at,
233 Some("2026-10-27T01:09:02.000Z".parse().unwrap())
234 );
235 assert_eq!(snapshot.next_regen_percent, Some(2.0));
236 let credits = snapshot.credits.expect("credit figures");
238 assert!((credits.used - 5.28).abs() < 1e-9);
239 assert_eq!(credits.max, 30.0);
240 }
241
242 #[test]
243 fn invalid_or_ambiguous_numbers_are_rejected() {
244 for raw in [
245 r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":-1,"nextRegenPercent":2}}"#,
246 r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"nextRegenPercent":-2}}"#,
247 r#"{"planWeeklyLimit":{"nextRegenAt":"not-a-time","percentRemaining":50,"nextRegenPercent":2}}"#,
248 r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"maxCredits":"0","remainingCredits":"1","nextRegenCredits":"0.60"}}"#,
249 r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"maxCredits":"30.00","remainingCredits":"-1","nextRegenCredits":"0.60"}}"#,
250 r#"{"subscription":{"limit":135,"requests":27,"renewsAt":"2026-11-01T00:00:00Z"}}"#,
251 r#"{"error":"unauthorized"}"#,
252 ] {
253 let result = serde_json::from_str::<Response>(raw)
254 .map_err(AppError::from)
255 .and_then(|response| response.snapshot());
256 assert!(result.is_err(), "accepted {raw}");
257 }
258 }
259
260 #[test]
261 fn over_quota_remaining_is_bounded_for_frontends() {
262 let response: Response = serde_json::from_str(
263 r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":120,"nextRegenPercent":2}}"#,
264 )
265 .unwrap();
266 assert_eq!(response.snapshot().unwrap().weekly.utilization_pct, 0);
267
268 let response: Response = serde_json::from_str(
269 r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":-0.4,"nextRegenPercent":2}}"#,
270 )
271 .unwrap();
272 assert!(response.snapshot().is_err());
274 }
275}