1use async_trait::async_trait;
2use chrono::{TimeZone, Utc};
3
4use crate::error::SpendPanelError;
5use crate::model::{CreditsSnapshot, PlanInfo, RateWindow, UsageSnapshot};
6use crate::provider::{ProviderContext, ProviderMetadata, UsageProvider};
7
8const DEFAULT_COOKIE_NAME: &str = "__Secure-next-auth.session-token";
10
11#[derive(Debug, serde::Deserialize)]
12struct PerplexityCreditsResponse {
13 #[serde(default)]
14 balance_cents: f64,
15 #[serde(default)]
16 renewal_date_ts: f64,
17 #[serde(default)]
18 current_period_purchased_cents: f64,
19 #[serde(default)]
20 credit_grants: Vec<PerplexityCreditGrant>,
21 #[serde(default)]
22 total_usage_cents: f64,
23}
24
25#[derive(Debug, serde::Deserialize)]
26struct PerplexityCreditGrant {
27 #[serde(rename = "type")]
28 grant_type: String,
29 #[serde(default)]
30 amount_cents: f64,
31 #[serde(default)]
32 expires_at_ts: Option<f64>,
33}
34
35#[derive(Debug, Clone, PartialEq)]
37struct PerplexityCredits {
38 recurring_total: f64,
39 recurring_used: f64,
40 promo_total: f64,
41 promo_used: f64,
42 purchased_total: f64,
43 purchased_used: f64,
44 balance_cents: f64,
45 total_usage_cents: f64,
46 renewal_ts: f64,
47 promo_expiry_ts: Option<f64>,
48}
49
50impl PerplexityCredits {
51 fn from_response(resp: &PerplexityCreditsResponse, now_ts: f64) -> Self {
53 let sum = |kind: &str| -> f64 {
54 resp.credit_grants
55 .iter()
56 .filter(|g| g.grant_type == kind)
57 .map(|g| g.amount_cents)
58 .sum::<f64>()
59 .max(0.0)
60 };
61
62 let recurring_sum = sum("recurring");
63 let promo_sum = resp
64 .credit_grants
65 .iter()
66 .filter(|g| g.grant_type == "promotional")
67 .filter(|g| g.expires_at_ts.unwrap_or(f64::INFINITY) > now_ts)
68 .map(|g| g.amount_cents)
69 .sum::<f64>()
70 .max(0.0);
71
72 let purchased_from_grants = sum("purchased");
75 let purchased_from_field = resp.current_period_purchased_cents.max(0.0);
76 let purchased_sum = purchased_from_grants.max(purchased_from_field);
77
78 let mut remaining = resp.total_usage_cents;
79 let used_from_recurring = remaining.min(recurring_sum).max(0.0);
80 remaining -= used_from_recurring;
81 let used_from_purchased = remaining.min(purchased_sum).max(0.0);
82 remaining -= used_from_purchased;
83 let used_from_promo = remaining.min(promo_sum).max(0.0);
84
85 let promo_expiry_ts = resp
86 .credit_grants
87 .iter()
88 .filter(|g| g.grant_type == "promotional")
89 .filter(|g| g.expires_at_ts.unwrap_or(f64::INFINITY) > now_ts)
90 .filter_map(|g| g.expires_at_ts)
91 .fold(None, |acc: Option<f64>, ts| {
92 Some(acc.map_or(ts, |cur| cur.min(ts)))
93 });
94
95 Self {
96 recurring_total: recurring_sum,
97 recurring_used: used_from_recurring,
98 promo_total: promo_sum,
99 promo_used: used_from_promo,
100 purchased_total: purchased_sum,
101 purchased_used: used_from_purchased,
102 balance_cents: resp.balance_cents,
103 total_usage_cents: resp.total_usage_cents,
104 renewal_ts: resp.renewal_date_ts,
105 promo_expiry_ts,
106 }
107 }
108
109 fn plan_name(&self) -> Option<&'static str> {
111 if self.recurring_total <= 0.0 {
112 None
113 } else if self.recurring_total < 5000.0 {
114 Some("Pro")
115 } else {
116 Some("Max")
117 }
118 }
119}
120
121pub struct PerplexityProvider {
123 metadata: ProviderMetadata,
124 base_url: Option<String>,
126}
127
128impl PerplexityProvider {
129 pub fn new() -> Self {
130 Self {
131 metadata: ProviderMetadata {
132 id: "perplexity",
133 name: "Perplexity",
134 description: "Perplexity AI credits monitor (browser cookie)",
135 auth_methods: &["cookie", "token", "env"],
136 website: Some("https://www.perplexity.ai"),
137 },
138 base_url: None,
139 }
140 }
141
142 pub fn with_base_url(url: &str) -> Self {
144 let mut p = Self::new();
145 p.base_url = Some(url.to_string());
146 p
147 }
148
149 fn api_base(&self) -> &str {
150 self.base_url
151 .as_deref()
152 .unwrap_or("https://www.perplexity.ai")
153 }
154
155 fn clean(raw: &str) -> String {
156 let mut value = raw.trim();
157 if value.len() >= 2
158 && ((value.starts_with('"') && value.ends_with('"'))
159 || (value.starts_with('\'') && value.ends_with('\'')))
160 {
161 value = &value[1..value.len() - 1];
162 }
163 value.trim().to_string()
164 }
165
166 fn resolve_cookie(ctx: &ProviderContext) -> Result<String, SpendPanelError> {
171 if let Some(cookie) = ctx.config.get("cookie") {
172 let cleaned = Self::clean(cookie);
173 if !cleaned.is_empty() {
174 return Ok(cleaned);
175 }
176 }
177 for key in ["token", "session_token", "api_key"] {
178 if let Some(value) = ctx.config.get(key) {
179 let cleaned = Self::clean(value);
180 if !cleaned.is_empty() {
181 return Ok(format!("{}={}", DEFAULT_COOKIE_NAME, cleaned));
182 }
183 }
184 }
185 if let Ok(value) = std::env::var("PERPLEXITY_SESSION_TOKEN") {
186 let cleaned = Self::clean(&value);
187 if !cleaned.is_empty() {
188 return Ok(format!("{}={}", DEFAULT_COOKIE_NAME, cleaned));
189 }
190 }
191 Err(SpendPanelError::AuthFailed(
192 "perplexity".into(),
193 "no session cookie found in cookie/token config or PERPLEXITY_SESSION_TOKEN".into(),
194 ))
195 }
196
197 fn build_client(ctx: &ProviderContext) -> Result<reqwest::Client, SpendPanelError> {
198 reqwest::Client::builder()
199 .timeout(std::time::Duration::from_secs(ctx.timeout_secs))
200 .build()
201 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))
202 }
203
204 async fn fetch_credits(
205 base_url: &str,
206 client: &reqwest::Client,
207 cookie: &str,
208 ) -> Result<PerplexityCreditsResponse, SpendPanelError> {
209 let url = format!(
210 "{}/rest/billing/credits?version=2.18&source=default",
211 base_url.trim_end_matches('/')
212 );
213 let resp = client
214 .get(url)
215 .header("Accept", "application/json")
216 .header("Cookie", cookie)
217 .header("Origin", "https://www.perplexity.ai")
218 .header("Referer", "https://www.perplexity.ai/account/usage")
219 .send()
220 .await
221 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
222
223 let status = resp.status();
224 let body = resp
225 .text()
226 .await
227 .map_err(|e| SpendPanelError::NetworkError(e.to_string()))?;
228
229 if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
230 return Err(SpendPanelError::AuthFailed(
231 "perplexity".into(),
232 format!(
233 "invalid or expired session cookie (HTTP {})",
234 status.as_u16()
235 ),
236 ));
237 }
238 if !status.is_success() {
239 return Err(SpendPanelError::ProviderError(
240 "perplexity".into(),
241 format!("HTTP {}: {}", status, body),
242 ));
243 }
244
245 serde_json::from_str(&body)
246 .map_err(|e| SpendPanelError::ParseError("perplexity".into(), e.to_string()))
247 }
248
249 fn ts_to_date(ts: f64) -> Option<chrono::DateTime<Utc>> {
251 if ts > 0.0 {
252 Utc.timestamp_opt(ts as i64, 0).single()
253 } else {
254 None
255 }
256 }
257
258 fn snapshot_from_credits(credits: PerplexityCredits) -> UsageSnapshot {
259 let mut snapshot = UsageSnapshot::new("perplexity");
260
261 if credits.recurring_total > 0.0 {
263 let mut window = RateWindow::new(
264 credits.recurring_used.round() as u64,
265 credits.recurring_total.round() as u64,
266 "Plan credits",
267 0,
268 );
269 window.resets_at = Self::ts_to_date(credits.renewal_ts);
270 snapshot.primary_rate_window = Some(window);
271 }
272
273 if credits.promo_total > 0.0 {
275 let mut window = RateWindow::new(
276 credits.promo_used.round() as u64,
277 credits.promo_total.round() as u64,
278 "Bonus credits",
279 0,
280 );
281 window.resets_at = credits
282 .promo_expiry_ts
283 .and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single());
284 snapshot.secondary_rate_window = Some(window);
285 }
286
287 if credits.purchased_total > 0.0 {
289 snapshot.tertiary_rate_window = Some(RateWindow::new(
290 credits.purchased_used.round() as u64,
291 credits.purchased_total.round() as u64,
292 "Purchased credits",
293 0,
294 ));
295 }
296
297 let mut credits_snapshot = CreditsSnapshot::new(credits.balance_cents / 100.0, "USD");
298 credits_snapshot.used = Some(credits.total_usage_cents / 100.0);
299 credits_snapshot.bonus = Some(credits.promo_total / 100.0);
300 credits_snapshot.purchased = Some(credits.purchased_total / 100.0);
301 credits_snapshot.renews_at = Self::ts_to_date(credits.renewal_ts);
302 snapshot.credits = Some(credits_snapshot);
303
304 if let Some(plan) = credits.plan_name() {
305 snapshot.plan = Some(PlanInfo {
306 name: plan.to_string(),
307 tier: None,
308 features: Vec::new(),
309 price: None,
310 currency: None,
311 billing_period: Some("monthly".into()),
312 });
313 }
314
315 snapshot
316 }
317}
318
319impl Default for PerplexityProvider {
320 fn default() -> Self {
321 Self::new()
322 }
323}
324
325#[async_trait]
326impl UsageProvider for PerplexityProvider {
327 fn metadata(&self) -> &ProviderMetadata {
328 &self.metadata
329 }
330
331 fn detect_credentials(&self) -> bool {
332 std::env::var("PERPLEXITY_SESSION_TOKEN")
333 .map(|v| !v.trim().is_empty())
334 .unwrap_or(false)
335 }
336
337 async fn fetch_usage(&self, ctx: &ProviderContext) -> Result<UsageSnapshot, SpendPanelError> {
338 let cookie = Self::resolve_cookie(ctx)?;
339 let client = Self::build_client(ctx)?;
340 let response = Self::fetch_credits(self.api_base(), &client, &cookie).await?;
341 let now_ts = Utc::now().timestamp() as f64;
342 let credits = PerplexityCredits::from_response(&response, now_ts);
343 Ok(Self::snapshot_from_credits(credits))
344 }
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use pretty_assertions::assert_eq;
351 use wiremock::matchers::{header, method, path};
352 use wiremock::{Mock, MockServer, ResponseTemplate};
353
354 const SAMPLE: &str = r#"{
355 "balance_cents": 1500.0,
356 "renewal_date_ts": 1788000000,
357 "current_period_purchased_cents": 1000.0,
358 "total_usage_cents": 700.0,
359 "credit_grants": [
360 {"type": "recurring", "amount_cents": 500.0},
361 {"type": "promotional", "amount_cents": 300.0, "expires_at_ts": 9999999999},
362 {"type": "purchased", "amount_cents": 1000.0}
363 ]
364 }"#;
365
366 fn parse(body: &str, now_ts: f64) -> PerplexityCredits {
367 let resp: PerplexityCreditsResponse = serde_json::from_str(body).unwrap();
368 PerplexityCredits::from_response(&resp, now_ts)
369 }
370
371 #[test]
372 fn test_metadata() {
373 let p = PerplexityProvider::new();
374 assert_eq!(p.metadata().id, "perplexity");
375 assert!(p.metadata().auth_methods.contains(&"cookie"));
376 }
377
378 #[test]
379 fn test_resolve_cookie_full_cookie_verbatim() {
380 let mut ctx = ProviderContext::new();
381 ctx.config.insert(
382 "cookie".into(),
383 "__Secure-next-auth.session-token=abc".into(),
384 );
385 assert_eq!(
386 PerplexityProvider::resolve_cookie(&ctx).unwrap(),
387 "__Secure-next-auth.session-token=abc"
388 );
389 }
390
391 #[test]
392 fn test_resolve_cookie_token_wrapped() {
393 let mut ctx = ProviderContext::new();
394 ctx.config.insert("token".into(), "sess-xyz".into());
395 assert_eq!(
396 PerplexityProvider::resolve_cookie(&ctx).unwrap(),
397 "__Secure-next-auth.session-token=sess-xyz"
398 );
399 }
400
401 #[test]
402 fn test_resolve_cookie_missing_is_error() {
403 let err = PerplexityProvider::resolve_cookie(&ProviderContext::new()).unwrap_err();
404 assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
405 }
406
407 #[test]
408 fn test_waterfall_attribution() {
409 let credits = parse(SAMPLE, 1.0);
411 assert_eq!(credits.recurring_total, 500.0);
412 assert_eq!(credits.recurring_used, 500.0);
413 assert_eq!(credits.purchased_total, 1000.0);
414 assert_eq!(credits.purchased_used, 200.0);
415 assert_eq!(credits.promo_total, 300.0);
416 assert_eq!(credits.promo_used, 0.0);
417 }
418
419 #[test]
420 fn test_expired_promo_excluded() {
421 let body = r#"{
422 "balance_cents": 0,
423 "renewal_date_ts": 0,
424 "current_period_purchased_cents": 0,
425 "total_usage_cents": 0,
426 "credit_grants": [
427 {"type": "promotional", "amount_cents": 300.0, "expires_at_ts": 100}
428 ]
429 }"#;
430 let credits = parse(body, 200.0);
431 assert_eq!(credits.promo_total, 0.0);
432 }
433
434 #[test]
435 fn test_plan_name_thresholds() {
436 let pro = PerplexityCredits {
437 recurring_total: 2000.0,
438 ..parse(SAMPLE, 1.0)
439 };
440 assert_eq!(pro.plan_name(), Some("Pro"));
441 let max = PerplexityCredits {
442 recurring_total: 10000.0,
443 ..parse(SAMPLE, 1.0)
444 };
445 assert_eq!(max.plan_name(), Some("Max"));
446 }
447
448 #[test]
449 fn test_snapshot_maps_pools() {
450 let snapshot = PerplexityProvider::snapshot_from_credits(parse(SAMPLE, 1.0));
451 let primary = snapshot.primary_rate_window.unwrap();
452 assert_eq!(primary.used, Some(500));
453 assert_eq!(primary.limit, Some(500));
454 let tertiary = snapshot.tertiary_rate_window.unwrap();
455 assert_eq!(tertiary.used, Some(200));
456 assert_eq!(tertiary.limit, Some(1000));
457 let credits = snapshot.credits.unwrap();
458 assert_eq!(credits.balance, 15.0);
459 assert_eq!(credits.purchased, Some(10.0));
460 }
461
462 #[test]
463 fn test_no_recurring_drops_primary_keeps_pools() {
464 let body = r#"{
466 "balance_cents": 500,
467 "renewal_date_ts": 0,
468 "current_period_purchased_cents": 800,
469 "total_usage_cents": 100,
470 "credit_grants": [
471 {"type": "purchased", "amount_cents": 800},
472 {"type": "promotional", "amount_cents": 200, "expires_at_ts": 9999999999}
473 ]
474 }"#;
475 let snapshot = PerplexityProvider::snapshot_from_credits(parse(body, 1.0));
476 assert!(
477 snapshot.primary_rate_window.is_none(),
478 "no recurring → no primary"
479 );
480 assert!(snapshot.secondary_rate_window.is_some());
481 assert!(snapshot.tertiary_rate_window.is_some());
482 assert!(snapshot.credits.unwrap().renews_at.is_none());
484 }
485
486 #[tokio::test]
487 async fn test_fetch_usage_success() {
488 let server = MockServer::start().await;
489 Mock::given(method("GET"))
490 .and(path("/rest/billing/credits"))
491 .and(header("cookie", "__Secure-next-auth.session-token=abc"))
492 .respond_with(ResponseTemplate::new(200).set_body_raw(SAMPLE, "application/json"))
493 .mount(&server)
494 .await;
495
496 let provider = PerplexityProvider::with_base_url(&server.uri());
497 let mut ctx = ProviderContext::new();
498 ctx.config.insert("token".into(), "abc".into());
499 let snapshot = provider.fetch_usage(&ctx).await.unwrap();
500 assert_eq!(snapshot.credits.unwrap().balance, 15.0);
501 }
502
503 #[tokio::test]
504 async fn test_fetch_usage_401_is_auth_failed() {
505 let server = MockServer::start().await;
506 Mock::given(method("GET"))
507 .and(path("/rest/billing/credits"))
508 .respond_with(ResponseTemplate::new(401))
509 .mount(&server)
510 .await;
511
512 let provider = PerplexityProvider::with_base_url(&server.uri());
513 let mut ctx = ProviderContext::new();
514 ctx.config.insert("token".into(), "bad".into());
515 let err = provider.fetch_usage(&ctx).await.unwrap_err();
516 assert!(matches!(err, SpendPanelError::AuthFailed(_, _)));
517 }
518}