use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use crate::error::{AppError, Result};
use crate::usage::{SyntheticSnapshot, UsageWindow, WeeklyCredits};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Response {
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_weekly_limit: Option<PlanWeeklyLimit>,
#[serde(skip_serializing_if = "Option::is_none")]
pub weekly_token_limit: Option<WeeklyTokenLimit>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanWeeklyLimit {
pub next_regen_at: String,
pub percent_remaining: f64,
pub next_regen_percent: f64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WeeklyTokenLimit {
pub next_regen_at: String,
pub percent_remaining: f64,
#[serde(deserialize_with = "de_credits")]
pub max_credits: f64,
#[serde(deserialize_with = "de_credits")]
pub remaining_credits: f64,
#[serde(deserialize_with = "de_credits")]
pub next_regen_credits: f64,
}
struct WeeklyFigures {
percent_remaining: f64,
renews_at: DateTime<Utc>,
next_regen_percent: Option<f64>,
credits: Option<WeeklyCredits>,
}
impl WeeklyFigures {
fn snapshot(self) -> SyntheticSnapshot {
let utilization_pct = (100.0 - self.percent_remaining).round() as i32;
let utilization_precise = ((100.0 - self.percent_remaining) * 100.0).round() / 100.0;
SyntheticSnapshot {
plan: None,
weekly: UsageWindow {
utilization_pct,
resets_at: Some(self.renews_at),
window_duration: chrono::Duration::zero(),
},
credits: self.credits,
next_regen_percent: self.next_regen_percent,
utilization_precise,
}
}
}
impl PlanWeeklyLimit {
fn figures(&self) -> Result<WeeklyFigures> {
Ok(WeeklyFigures {
percent_remaining: parse_percent(self.percent_remaining)?,
renews_at: parse_renews_at(&self.next_regen_at)?,
next_regen_percent: Some(parse_regens(self.next_regen_percent)?),
credits: None,
})
}
}
impl WeeklyTokenLimit {
fn figures(&self) -> Result<WeeklyFigures> {
let (max, remaining, next_regen) = (
self.max_credits,
self.remaining_credits,
self.next_regen_credits,
);
if !max.is_finite()
|| max <= 0.0
|| !remaining.is_finite()
|| remaining < 0.0
|| !next_regen.is_finite()
|| next_regen < 0.0
{
return Err(AppError::Schema(
"Synthetic weekly credit budget contains an invalid amount".into(),
));
}
Ok(WeeklyFigures {
percent_remaining: parse_percent(self.percent_remaining)?,
renews_at: parse_renews_at(&self.next_regen_at)?,
next_regen_percent: None,
credits: Some(WeeklyCredits {
used: (max - remaining).max(0.0),
max,
next_regen,
}),
})
}
}
impl Response {
pub fn snapshot(&self) -> Result<SyntheticSnapshot> {
let mut figures = match (&self.plan_weekly_limit, &self.weekly_token_limit) {
(Some(plan), _) => plan.figures()?,
(None, Some(tokens)) => tokens.figures()?,
(None, None) => {
return Err(AppError::Schema(
"Synthetic quota response carries no weekly limit".into(),
));
}
};
if figures.credits.is_none()
&& let Some(tokens) = &self.weekly_token_limit
&& let Ok(extra) = tokens.figures()
{
figures.credits = extra.credits;
}
Ok(figures.snapshot())
}
}
fn parse_percent(value: f64) -> Result<f64> {
if !value.is_finite() || value < 0.0 {
return Err(AppError::Schema(
"Synthetic weekly quota contains an invalid remaining percentage".into(),
));
}
Ok(value.clamp(0.0, 100.0))
}
fn parse_regens(value: f64) -> Result<f64> {
if !value.is_finite() || value < 0.0 {
return Err(AppError::Schema(
"Synthetic weekly quota contains an invalid regeneration amount".into(),
));
}
Ok(value)
}
fn parse_renews_at(raw: &str) -> Result<DateTime<Utc>> {
DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|_| AppError::Schema("Synthetic weekly quota renewal is invalid".into()))
}
fn de_credits<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error as _;
match Value::deserialize(deserializer)? {
Value::Number(number) => number
.as_f64()
.ok_or_else(|| D::Error::custom("credit amount is not a finite number")),
Value::String(raw) => {
let cleaned = raw.trim().trim_start_matches('$').replace(',', "");
cleaned
.parse::<f64>()
.map_err(|_| D::Error::custom("credit amount is not a number"))
}
_ => Err(D::Error::custom(
"credit amount must be a number or numeric string",
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLAN: &str = r#"{"planWeeklyLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":68.97415291835814,"nextRegenPercent":2}}"#;
const TOKENS: &str = r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":82.4,"maxCredits":"30.00","remainingCredits":"24.72","nextRegenCredits":"0.60"}}"#;
#[test]
fn plan_shape_projects_the_documented_weekly_example() {
let response: Response = serde_json::from_str(PLAN).unwrap();
let snapshot = response.snapshot().unwrap();
assert_eq!(snapshot.weekly.utilization_pct, 31);
assert_eq!(snapshot.utilization_precise, 31.03);
assert_eq!(
snapshot.weekly.resets_at,
Some("2026-10-27T01:09:02.000Z".parse().unwrap())
);
assert_eq!(snapshot.weekly.window_duration, chrono::Duration::zero());
assert_eq!(snapshot.credits, None);
assert_eq!(snapshot.next_regen_percent, Some(2.0));
}
#[test]
fn token_shape_reports_the_credit_budget() {
let response: Response = serde_json::from_str(TOKENS).unwrap();
let snapshot = response.snapshot().unwrap();
assert_eq!(snapshot.weekly.utilization_pct, 18);
let credits = snapshot.credits.expect("credit figures");
assert!((credits.used - 5.28).abs() < 1e-9);
assert_eq!(credits.max, 30.0);
assert_eq!(credits.next_regen, 0.6);
}
#[test]
fn plan_shape_wins_but_adopts_token_credits() {
let response: Response = serde_json::from_str(
r#"{"planWeeklyLimit":{"nextRegenAt":"2026-10-27T01:09:02.000Z","percentRemaining":68.97415291835814,"nextRegenPercent":2},
"weeklyTokenLimit":{"nextRegenAt":"2026-10-27T05:00:00.000Z","percentRemaining":82.4,"maxCredits":"30.00","remainingCredits":"24.72","nextRegenCredits":"0.60"}}"#,
)
.unwrap();
let snapshot = response.snapshot().unwrap();
assert_eq!(snapshot.weekly.utilization_pct, 31);
assert_eq!(
snapshot.weekly.resets_at,
Some("2026-10-27T01:09:02.000Z".parse().unwrap())
);
assert_eq!(snapshot.next_regen_percent, Some(2.0));
let credits = snapshot.credits.expect("credit figures");
assert!((credits.used - 5.28).abs() < 1e-9);
assert_eq!(credits.max, 30.0);
}
#[test]
fn invalid_or_ambiguous_numbers_are_rejected() {
for raw in [
r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":-1,"nextRegenPercent":2}}"#,
r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"nextRegenPercent":-2}}"#,
r#"{"planWeeklyLimit":{"nextRegenAt":"not-a-time","percentRemaining":50,"nextRegenPercent":2}}"#,
r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"maxCredits":"0","remainingCredits":"1","nextRegenCredits":"0.60"}}"#,
r#"{"weeklyTokenLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":50,"maxCredits":"30.00","remainingCredits":"-1","nextRegenCredits":"0.60"}}"#,
r#"{"subscription":{"limit":135,"requests":27,"renewsAt":"2026-11-01T00:00:00Z"}}"#,
r#"{"error":"unauthorized"}"#,
] {
let result = serde_json::from_str::<Response>(raw)
.map_err(AppError::from)
.and_then(|response| response.snapshot());
assert!(result.is_err(), "accepted {raw}");
}
}
#[test]
fn over_quota_remaining_is_bounded_for_frontends() {
let response: Response = serde_json::from_str(
r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":120,"nextRegenPercent":2}}"#,
)
.unwrap();
assert_eq!(response.snapshot().unwrap().weekly.utilization_pct, 0);
let response: Response = serde_json::from_str(
r#"{"planWeeklyLimit":{"nextRegenAt":"2026-11-01T00:00:00Z","percentRemaining":-0.4,"nextRegenPercent":2}}"#,
)
.unwrap();
assert!(response.snapshot().is_err());
}
}