Files
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

142 lines
4.7 KiB
Rust

//! Typed client for the jcode subscription backend account endpoint.
//!
//! `GET /v1/me` is the source of truth for the account's tier and usage. The
//! last-known tier is persisted via
//! [`crate::subscription_catalog::store_cached_tier`] so model gating works
//! offline (unknown/absent tier behaves like Plus).
use crate::subscription_catalog::{self, JcodeTier};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Timeout for the short, non-blocking status fetch used by the TUI.
pub const ME_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionUsage {
pub used_usd: f64,
pub budget_usd: f64,
/// RFC 3339 timestamp for when the usage window resets.
#[serde(default)]
pub resets_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionMe {
pub account_id: String,
pub email: String,
/// Wire tier value, e.g. "plus" or "flagship".
pub tier: String,
pub status: String,
pub usage: SubscriptionUsage,
}
impl SubscriptionMe {
pub fn parsed_tier(&self) -> Option<JcodeTier> {
JcodeTier::parse(&self.tier)
}
}
/// The `/v1/me` endpoint URL for the configured (or default) API base.
pub fn me_endpoint_url() -> String {
let base = subscription_catalog::configured_api_base()
.unwrap_or_else(|| subscription_catalog::DEFAULT_JCODE_API_BASE.to_string());
format!("{}/me", base.trim_end_matches('/'))
}
/// Fetch the subscription account status from the backend using the
/// configured `JCODE_API_KEY` / `JCODE_API_BASE`.
///
/// On success, persists the reported tier as the last-known tier so offline
/// model gating stays accurate.
pub async fn fetch_subscription_me() -> Result<SubscriptionMe> {
let api_key = subscription_catalog::configured_api_key()
.context("no jcode subscription credential configured (run /login jcode)")?;
let client = crate::provider::shared_http_client();
let response = client
.get(me_endpoint_url())
.bearer_auth(api_key)
.timeout(ME_FETCH_TIMEOUT)
.send()
.await
.context("failed to reach the jcode subscription API")?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
anyhow::bail!(
"jcode subscription API returned {}: {}",
status,
body.chars().take(200).collect::<String>()
);
}
let me: SubscriptionMe = response
.json()
.await
.context("failed to parse jcode subscription /me response")?;
// Persist the backend-reported tier as the local source of truth for
// offline gating. Best-effort; failure to persist should not fail the fetch.
if let Some(tier) = me.parsed_tier() {
let _ = subscription_catalog::store_cached_tier(Some(tier));
}
Ok(me)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subscription_me_parses_expected_shape() {
let json = r#"{
"account_id": "acct_123",
"email": "dev@example.com",
"tier": "flagship",
"status": "active",
"usage": {
"used_usd": 12.5,
"budget_usd": 3000.0,
"resets_at": "2026-08-01T00:00:00Z"
}
}"#;
let me: SubscriptionMe = serde_json::from_str(json).expect("parse SubscriptionMe");
assert_eq!(me.account_id, "acct_123");
assert_eq!(me.email, "dev@example.com");
assert_eq!(me.parsed_tier(), Some(JcodeTier::Flagship));
assert_eq!(me.status, "active");
assert_eq!(me.usage.used_usd, 12.5);
assert_eq!(me.usage.budget_usd, 3000.0);
assert_eq!(me.usage.resets_at.as_deref(), Some("2026-08-01T00:00:00Z"));
}
#[test]
fn subscription_me_tolerates_missing_resets_at_and_unknown_tier() {
let json = r#"{
"account_id": "acct_9",
"email": "x@example.com",
"tier": "mystery",
"status": "active",
"usage": { "used_usd": 0.0, "budget_usd": 18.0 }
}"#;
let me: SubscriptionMe = serde_json::from_str(json).expect("parse SubscriptionMe");
assert_eq!(me.parsed_tier(), None);
assert!(me.usage.resets_at.is_none());
}
#[test]
fn me_endpoint_url_appends_me_to_configured_base() {
let _guard = crate::storage::lock_test_env();
crate::env::set_var(
subscription_catalog::JCODE_API_BASE_ENV,
"https://api.jcode.sh/v1/",
);
assert_eq!(me_endpoint_url(), "https://api.jcode.sh/v1/me");
crate::env::remove_var(subscription_catalog::JCODE_API_BASE_ENV);
}
}