use serde::Deserialize;
use crate::error::{AppError, Result};
use crate::usage::DeepseekSnapshot;
#[derive(Debug, Clone, Deserialize)]
pub struct BalanceResponse {
pub is_available: bool,
pub balance_infos: Vec<BalanceInfo>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BalanceInfo {
pub currency: String,
pub total_balance: String,
pub granted_balance: String,
pub topped_up_balance: String,
}
impl BalanceResponse {
pub fn into_snapshot(self) -> Result<DeepseekSnapshot> {
fn unique<'a>(infos: &'a [BalanceInfo], currency: &str) -> Result<Option<&'a BalanceInfo>> {
let mut matching = infos.iter().filter(|info| info.currency == currency);
let first = matching.next();
if matching.next().is_some() {
return Err(AppError::Schema(format!(
"deepseek: response carried duplicate {currency} balances"
)));
}
Ok(first)
}
let info = unique(&self.balance_infos, "USD")?
.or(unique(&self.balance_infos, "CNY")?)
.ok_or_else(|| {
let currencies = self
.balance_infos
.iter()
.map(|info| info.currency.as_str())
.collect::<Vec<_>>()
.join(", ");
AppError::Schema(if currencies.is_empty() {
"deepseek: response carried no balance records".into()
} else {
format!("deepseek: response carried no USD or CNY balance (saw {currencies})")
})
})?;
Ok(DeepseekSnapshot {
is_available: self.is_available,
balance: parse_money(&info.total_balance, "total_balance")?,
granted: parse_money(&info.granted_balance, "granted_balance")?,
topped_up: parse_money(&info.topped_up_balance, "topped_up_balance")?,
currency: info.currency.clone(),
})
}
}
fn parse_money(s: &str, field: &str) -> Result<f64> {
let n: f64 = s.trim().parse().map_err(|_| {
AppError::Schema(format!("deepseek balance '{field}' is not numeric: {s:?}"))
})?;
if n.is_finite() {
Ok(n)
} else {
Err(AppError::Schema(format!(
"deepseek balance '{field}' is not finite"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_balance_response() {
let raw = r#"{
"is_available": true,
"balance_infos": [
{"currency": "CNY", "total_balance": "10.00", "granted_balance": "10.00", "topped_up_balance": "0.00"},
{"currency": "USD", "total_balance": "1.50", "granted_balance": "1.50", "topped_up_balance": "0.00"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
let snap = r.into_snapshot().unwrap();
assert!(snap.is_available);
assert_eq!(snap.currency, "USD");
assert!((snap.balance - 1.50).abs() < 1e-9);
}
#[test]
fn fallback_to_cny_when_no_usd() {
let raw = r#"{
"is_available": true,
"balance_infos": [
{"currency": "CNY", "total_balance": "20.00", "granted_balance": "20.00", "topped_up_balance": "0.00"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
let snap = r.into_snapshot().unwrap();
assert_eq!(snap.currency, "CNY");
assert!((snap.balance - 20.0).abs() < 1e-9);
}
#[test]
fn genuine_zero_balance_is_preserved() {
let raw = r#"{
"is_available": false,
"balance_infos": [
{"currency": "USD", "total_balance": "0.00", "granted_balance": "0.00", "topped_up_balance": "0.00"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
let snap = r.into_snapshot().unwrap();
assert!(!snap.is_available);
assert_eq!(snap.balance, 0.0);
assert_eq!(snap.currency, "USD");
}
#[test]
fn empty_balance_infos_is_schema_error() {
let raw = r#"{"is_available": false, "balance_infos": []}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
}
#[test]
fn unsupported_currency_is_schema_error_not_usd_scaled() {
let raw = r#"{
"is_available": true,
"balance_infos": [
{"currency": "EUR", "total_balance": "20.00", "granted_balance": "20.00", "topped_up_balance": "0.00"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
let err = r.into_snapshot().unwrap_err().to_string();
assert!(err.contains("no USD or CNY"), "{err}");
assert!(err.contains("EUR"), "{err}");
}
#[test]
fn duplicate_supported_currency_is_schema_error_not_first_wins() {
let raw = r#"{
"is_available": true,
"balance_infos": [
{"currency": "USD", "total_balance": "1.00", "granted_balance": "1.00", "topped_up_balance": "0.00"},
{"currency": "USD", "total_balance": "2.00", "granted_balance": "2.00", "topped_up_balance": "0.00"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
let err = r.into_snapshot().unwrap_err().to_string();
assert!(err.contains("duplicate USD"), "{err}");
}
#[test]
fn non_numeric_amount_is_schema_error() {
let raw = r#"{
"is_available": true,
"balance_infos": [
{"currency": "USD", "total_balance": "n/a", "granted_balance": "0.00", "topped_up_balance": "0.00"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
}
#[test]
fn empty_amount_is_schema_error() {
let raw = r#"{
"is_available": true,
"balance_infos": [
{"currency": "USD", "total_balance": "", "granted_balance": "0.00", "topped_up_balance": "0.00"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
}
#[test]
fn non_finite_amounts_are_schema_errors() {
for amount in ["NaN", "inf", "-inf", "infinity"] {
let raw = format!(
r#"{{"is_available": true, "balance_infos": [
{{"currency": "USD", "total_balance": "{amount}",
"granted_balance": "0.00", "topped_up_balance": "0.00"}}
]}}"#
);
let r: BalanceResponse = serde_json::from_str(&raw).unwrap();
assert!(
matches!(r.into_snapshot(), Err(AppError::Schema(_))),
"{amount} should be rejected"
);
}
}
#[test]
fn non_numeric_component_is_schema_error() {
let raw = r#"{
"is_available": true,
"balance_infos": [
{"currency": "USD", "total_balance": "5.00", "granted_balance": "5.00", "topped_up_balance": "oops"}
]
}"#;
let r: BalanceResponse = serde_json::from_str(raw).unwrap();
assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
}
#[test]
fn empty_body_does_not_deserialize() {
assert!(serde_json::from_str::<BalanceResponse>("{}").is_err());
}
#[test]
fn error_body_does_not_deserialize() {
let raw = r#"{"error": {"message": "Authentication Fails"}}"#;
assert!(serde_json::from_str::<BalanceResponse>(raw).is_err());
}
#[test]
fn missing_money_field_does_not_deserialize() {
let raw = r#"{
"is_available": true,
"balance_infos": [{"currency": "USD", "total_balance": "5.00"}]
}"#;
assert!(serde_json::from_str::<BalanceResponse>(raw).is_err());
}
}