use serde::Deserialize;
use crate::usage::{Cents, OrcaRouterSnapshot};
pub const UNLIMITED_LIMIT_USD: f64 = 100_000_000.0;
#[derive(Debug, Clone, Deserialize)]
pub struct UsageResponse {
#[serde(default)]
pub object: Option<String>,
#[serde(deserialize_with = "de_cents")]
pub total_usage: Cents,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SubscriptionResponse {
#[serde(default)]
pub object: Option<String>,
#[serde(default)]
pub has_payment_method: Option<bool>,
#[serde(default, deserialize_with = "de_opt_usd_cents")]
pub soft_limit_usd: Option<Cents>,
#[serde(default, deserialize_with = "de_opt_usd_cents")]
pub hard_limit_usd: Option<Cents>,
#[serde(default, deserialize_with = "de_opt_usd_cents")]
pub system_hard_limit_usd: Option<Cents>,
#[serde(default)]
pub access_until: i64,
}
impl SubscriptionResponse {
pub fn limit_cents(&self) -> Option<i64> {
self.hard_limit_usd
.or(self.soft_limit_usd)
.or(self.system_hard_limit_usd)
.filter(|cents| cents.0 > 0)
.map(|cents| cents.0)
}
pub fn access_until_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
(self.access_until > 0)
.then(|| chrono::DateTime::from_timestamp(self.access_until, 0))
.flatten()
}
}
pub fn combine(usage: &UsageResponse, subscription: &SubscriptionResponse) -> OrcaRouterSnapshot {
OrcaRouterSnapshot {
spent_cents: usage.total_usage.0,
limit_cents: subscription.limit_cents(),
access_until: subscription.access_until_at(),
}
}
fn de_cents<'de, D>(d: D) -> Result<Cents, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = f64::deserialize(d)?;
if !raw.is_finite() {
return Err(serde::de::Error::custom(
"orcarouter `total_usage` is not a finite number",
));
}
if raw < 0.0 {
return Err(serde::de::Error::custom(
"orcarouter `total_usage` cannot be negative",
));
}
if raw > i64::MAX as f64 {
return Err(serde::de::Error::custom(
"orcarouter `total_usage` is out of range",
));
}
Ok(Cents(raw.round() as i64))
}
fn de_opt_usd_cents<'de, D>(d: D) -> Result<Option<Cents>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<f64> = Option::deserialize(d)?;
raw.map(|v| {
if v == UNLIMITED_LIMIT_USD {
Ok(Cents(0))
} else if !v.is_finite() || v < 0.0 {
Err(format!(
"orcarouter `limit` is not finite and non-negative: {v}"
))
} else {
let cents = v * 100.0;
if cents > i64::MAX as f64 {
Err(format!("orcarouter `limit` is out of range: {v}"))
} else {
Ok(Cents(cents.round() as i64))
}
}
})
.transpose()
.map_err(serde::de::Error::custom)
}
#[cfg(test)]
mod tests {
use super::*;
const USAGE: &str = r#"{"object":"list","total_usage":275}"#;
const SUBSCRIPTION: &str = r#"{
"object":"billing_subscription",
"has_payment_method":true,
"soft_limit_usd":12.5,
"hard_limit_usd":12.5,
"system_hard_limit_usd":12.5,
"access_until":1790000000
}"#;
#[test]
fn parses_usage_verbatim_shape() {
let usage: UsageResponse = serde_json::from_str(USAGE).unwrap();
assert_eq!(usage.object.as_deref(), Some("list"));
assert_eq!(usage.total_usage.0, 275);
}
#[test]
fn parses_usage_without_object() {
let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":0}"#).unwrap();
assert!(usage.object.is_none());
assert_eq!(usage.total_usage.0, 0);
}
#[test]
fn missing_total_usage_is_schema_drift_not_zero() {
assert!(serde_json::from_str::<UsageResponse>(r#"{"object":"list"}"#).is_err());
assert!(serde_json::from_str::<UsageResponse>(r#"{}"#).is_err());
}
#[test]
fn combine_keeps_cents_exact() {
let usage: UsageResponse = serde_json::from_str(USAGE).unwrap();
let sub: SubscriptionResponse = serde_json::from_str(SUBSCRIPTION).unwrap();
let snap = combine(&usage, &sub);
assert_eq!(snap.spent_cents, 275);
assert!((snap.spent_usd() - 2.75).abs() < 1e-9);
assert_eq!(snap.limit_cents, Some(1250));
assert_eq!(snap.remaining_cents(), Some(975));
assert!((snap.remaining_usd().unwrap() - 9.75).abs() < 1e-9);
assert_eq!(snap.consumed_pct(), Some(22));
assert_eq!(
snap.access_until,
chrono::DateTime::from_timestamp(1_790_000_000, 0)
);
}
#[test]
fn fractional_cents_round_to_the_nearest_cent() {
let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":275.4}"#).unwrap();
assert_eq!(usage.total_usage.0, 275);
let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":275.5}"#).unwrap();
assert_eq!(usage.total_usage.0, 276);
}
#[test]
fn invalid_usage_money_is_schema_drift() {
for raw in ["-1", "null", "true", r#""275""#, "1e400"] {
let body = format!(r#"{{"total_usage":{raw}}}"#);
assert!(
serde_json::from_str::<UsageResponse>(&body).is_err(),
"{raw}"
);
}
}
#[test]
fn parses_subscription_without_optional_fields() {
let sub: SubscriptionResponse = serde_json::from_str(
r#"{"soft_limit_usd":5.0,"hard_limit_usd":5.0,"system_hard_limit_usd":5.0}"#,
)
.unwrap();
assert!(sub.object.is_none());
assert!(sub.has_payment_method.is_none());
assert_eq!(sub.access_until, 0);
assert!(sub.access_until_at().is_none());
assert_eq!(sub.limit_cents(), Some(500));
}
#[test]
fn limit_falls_back_when_only_some_fields_arrive() {
let sub: SubscriptionResponse =
serde_json::from_str(r#"{"system_hard_limit_usd":7.5}"#).unwrap();
assert_eq!(sub.limit_cents(), Some(750));
let none: SubscriptionResponse = serde_json::from_str("{}").unwrap();
assert_eq!(none.limit_cents(), None);
}
#[test]
fn unlimited_sentinel_is_no_limit_not_one_hundred_million() {
let sub: SubscriptionResponse = serde_json::from_str(
r#"{"soft_limit_usd":100000000,"hard_limit_usd":100000000,"system_hard_limit_usd":100000000}"#,
)
.unwrap();
assert_eq!(sub.limit_cents(), None);
let usage: UsageResponse = serde_json::from_str(r#"{"total_usage":275}"#).unwrap();
let snap = combine(&usage, &sub);
assert_eq!(snap.limit_cents, None);
assert_eq!(snap.remaining_cents(), None);
assert_eq!(snap.remaining_usd(), None);
assert_eq!(snap.consumed_pct(), None);
assert_eq!(snap.spent_cents, 275);
}
#[test]
fn access_until_zero_means_no_expiry() {
let sub: SubscriptionResponse =
serde_json::from_str(r#"{"hard_limit_usd":5.0,"access_until":0}"#).unwrap();
assert!(sub.access_until_at().is_none());
}
#[test]
fn invalid_limit_money_is_schema_drift() {
let body = r#"{"hard_limit_usd":-5.0}"#;
assert!(serde_json::from_str::<SubscriptionResponse>(body).is_err());
}
#[test]
fn overrun_keeps_a_negative_remaining() {
let snap = OrcaRouterSnapshot {
spent_cents: 1300,
limit_cents: Some(1250),
access_until: None,
};
assert_eq!(snap.remaining_cents(), Some(-50));
assert_eq!(snap.consumed_pct(), Some(100));
}
}