1use serde::Deserialize;
4
5use crate::error::{AppError, Result};
6use crate::usage::DeepseekSnapshot;
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct BalanceResponse {
10 pub is_available: bool,
11 pub balance_infos: Vec<BalanceInfo>,
12}
13
14#[derive(Debug, Clone, Deserialize)]
15pub struct BalanceInfo {
16 pub currency: String,
17 pub total_balance: String,
18 pub granted_balance: String,
19 pub topped_up_balance: String,
20}
21
22impl BalanceResponse {
23 pub fn into_snapshot(self) -> Result<DeepseekSnapshot> {
29 fn unique<'a>(infos: &'a [BalanceInfo], currency: &str) -> Result<Option<&'a BalanceInfo>> {
34 let mut matching = infos.iter().filter(|info| info.currency == currency);
35 let first = matching.next();
36 if matching.next().is_some() {
37 return Err(AppError::Schema(format!(
38 "deepseek: response carried duplicate {currency} balances"
39 )));
40 }
41 Ok(first)
42 }
43 let info = unique(&self.balance_infos, "USD")?
44 .or(unique(&self.balance_infos, "CNY")?)
45 .ok_or_else(|| {
46 let currencies = self
47 .balance_infos
48 .iter()
49 .map(|info| info.currency.as_str())
50 .collect::<Vec<_>>()
51 .join(", ");
52 AppError::Schema(if currencies.is_empty() {
53 "deepseek: response carried no balance records".into()
54 } else {
55 format!("deepseek: response carried no USD or CNY balance (saw {currencies})")
56 })
57 })?;
58
59 Ok(DeepseekSnapshot {
60 is_available: self.is_available,
61 balance: parse_money(&info.total_balance, "total_balance")?,
62 granted: parse_money(&info.granted_balance, "granted_balance")?,
63 topped_up: parse_money(&info.topped_up_balance, "topped_up_balance")?,
64 currency: info.currency.clone(),
65 })
66 }
67}
68
69fn parse_money(s: &str, field: &str) -> Result<f64> {
73 let n: f64 = s.trim().parse().map_err(|_| {
74 AppError::Schema(format!("deepseek balance '{field}' is not numeric: {s:?}"))
75 })?;
76 if n.is_finite() {
77 Ok(n)
78 } else {
79 Err(AppError::Schema(format!(
80 "deepseek balance '{field}' is not finite"
81 )))
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn parses_balance_response() {
91 let raw = r#"{
92 "is_available": true,
93 "balance_infos": [
94 {"currency": "CNY", "total_balance": "10.00", "granted_balance": "10.00", "topped_up_balance": "0.00"},
95 {"currency": "USD", "total_balance": "1.50", "granted_balance": "1.50", "topped_up_balance": "0.00"}
96 ]
97 }"#;
98 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
99 let snap = r.into_snapshot().unwrap();
100 assert!(snap.is_available);
101 assert_eq!(snap.currency, "USD");
102 assert!((snap.balance - 1.50).abs() < 1e-9);
103 }
104
105 #[test]
106 fn fallback_to_cny_when_no_usd() {
107 let raw = r#"{
108 "is_available": true,
109 "balance_infos": [
110 {"currency": "CNY", "total_balance": "20.00", "granted_balance": "20.00", "topped_up_balance": "0.00"}
111 ]
112 }"#;
113 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
114 let snap = r.into_snapshot().unwrap();
115 assert_eq!(snap.currency, "CNY");
116 assert!((snap.balance - 20.0).abs() < 1e-9);
117 }
118
119 #[test]
121 fn genuine_zero_balance_is_preserved() {
122 let raw = r#"{
123 "is_available": false,
124 "balance_infos": [
125 {"currency": "USD", "total_balance": "0.00", "granted_balance": "0.00", "topped_up_balance": "0.00"}
126 ]
127 }"#;
128 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
129 let snap = r.into_snapshot().unwrap();
130 assert!(!snap.is_available);
131 assert_eq!(snap.balance, 0.0);
132 assert_eq!(snap.currency, "USD");
133 }
134
135 #[test]
136 fn empty_balance_infos_is_schema_error() {
137 let raw = r#"{"is_available": false, "balance_infos": []}"#;
138 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
139 assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
140 }
141
142 #[test]
143 fn unsupported_currency_is_schema_error_not_usd_scaled() {
144 let raw = r#"{
145 "is_available": true,
146 "balance_infos": [
147 {"currency": "EUR", "total_balance": "20.00", "granted_balance": "20.00", "topped_up_balance": "0.00"}
148 ]
149 }"#;
150 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
151 let err = r.into_snapshot().unwrap_err().to_string();
152 assert!(err.contains("no USD or CNY"), "{err}");
153 assert!(err.contains("EUR"), "{err}");
154 }
155
156 #[test]
157 fn duplicate_supported_currency_is_schema_error_not_first_wins() {
158 let raw = r#"{
159 "is_available": true,
160 "balance_infos": [
161 {"currency": "USD", "total_balance": "1.00", "granted_balance": "1.00", "topped_up_balance": "0.00"},
162 {"currency": "USD", "total_balance": "2.00", "granted_balance": "2.00", "topped_up_balance": "0.00"}
163 ]
164 }"#;
165 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
166 let err = r.into_snapshot().unwrap_err().to_string();
167 assert!(err.contains("duplicate USD"), "{err}");
168 }
169
170 #[test]
171 fn non_numeric_amount_is_schema_error() {
172 let raw = r#"{
173 "is_available": true,
174 "balance_infos": [
175 {"currency": "USD", "total_balance": "n/a", "granted_balance": "0.00", "topped_up_balance": "0.00"}
176 ]
177 }"#;
178 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
179 assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
180 }
181
182 #[test]
183 fn empty_amount_is_schema_error() {
184 let raw = r#"{
185 "is_available": true,
186 "balance_infos": [
187 {"currency": "USD", "total_balance": "", "granted_balance": "0.00", "topped_up_balance": "0.00"}
188 ]
189 }"#;
190 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
191 assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
192 }
193
194 #[test]
197 fn non_finite_amounts_are_schema_errors() {
198 for amount in ["NaN", "inf", "-inf", "infinity"] {
199 let raw = format!(
200 r#"{{"is_available": true, "balance_infos": [
201 {{"currency": "USD", "total_balance": "{amount}",
202 "granted_balance": "0.00", "topped_up_balance": "0.00"}}
203 ]}}"#
204 );
205 let r: BalanceResponse = serde_json::from_str(&raw).unwrap();
206 assert!(
207 matches!(r.into_snapshot(), Err(AppError::Schema(_))),
208 "{amount} should be rejected"
209 );
210 }
211 }
212
213 #[test]
215 fn non_numeric_component_is_schema_error() {
216 let raw = r#"{
217 "is_available": true,
218 "balance_infos": [
219 {"currency": "USD", "total_balance": "5.00", "granted_balance": "5.00", "topped_up_balance": "oops"}
220 ]
221 }"#;
222 let r: BalanceResponse = serde_json::from_str(raw).unwrap();
223 assert!(matches!(r.into_snapshot(), Err(AppError::Schema(_))));
224 }
225
226 #[test]
227 fn empty_body_does_not_deserialize() {
228 assert!(serde_json::from_str::<BalanceResponse>("{}").is_err());
229 }
230
231 #[test]
232 fn error_body_does_not_deserialize() {
233 let raw = r#"{"error": {"message": "Authentication Fails"}}"#;
234 assert!(serde_json::from_str::<BalanceResponse>(raw).is_err());
235 }
236
237 #[test]
238 fn missing_money_field_does_not_deserialize() {
239 let raw = r#"{
240 "is_available": true,
241 "balance_infos": [{"currency": "USD", "total_balance": "5.00"}]
242 }"#;
243 assert!(serde_json::from_str::<BalanceResponse>(raw).is_err());
244 }
245}