diff --git a/src/api/types.rs b/src/api/types.rs index 78d2933..681d896 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -2,13 +2,11 @@ use serde::{Deserialize, Serialize}; // --- Billing / Account --- -/// Only `total_credits_left` and `plan` are load-bearing (auth verify, -/// credits display). Everything else defaults so one renamed/removed field -/// in Suno's billing response doesn't break credits+models+auth at once. #[derive(Debug, Deserialize, Serialize)] pub struct BillingInfo { #[serde(default)] pub credits: u64, + #[serde(default)] pub total_credits_left: u64, #[serde(default)] pub monthly_usage: u64, @@ -16,26 +14,69 @@ pub struct BillingInfo { pub monthly_limit: u64, #[serde(default)] pub is_active: bool, - pub plan: Plan, + #[serde(default)] + pub is_past_due: bool, + /// Suno no longer nests the active plan under a `plan` key. Instead + /// `subscription_type` is `false` on the free tier or the plan's + /// `plan_key` string (e.g. `"pro"`) when subscribed, and the full catalog + /// of plans (with pricing/features) is listed separately in `plans`. + #[serde(default)] + pub subscription_type: SubscriptionType, #[serde(default)] pub models: Vec, #[serde(default)] - pub period: String, + pub plans: Vec, #[serde(default)] - pub renews_on: Option, + pub accessible_features: Vec, #[serde(default)] pub remaster_model_types: Vec, } #[derive(Debug, Deserialize, Serialize)] -pub struct Plan { - pub name: String, - #[serde(default)] +#[serde(untagged)] +pub enum SubscriptionType { + /// No active paid subscription (free tier). + None(bool), + /// Active plan, identified by its `plan_key` (e.g. "pro"). + Plan(String), +} + +impl Default for SubscriptionType { + fn default() -> Self { + SubscriptionType::None(false) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PlanOption { pub plan_key: String, + pub name: String, #[serde(default)] pub usage_plan_features: Vec, } +impl BillingInfo { + /// Human-readable name of the account's current plan, resolved from + /// `subscription_type` against the `plans` catalog (falls back to the + /// raw plan key, or "Free" when there's no active subscription). + pub fn plan_name(&self) -> String { + match &self.subscription_type { + SubscriptionType::Plan(key) => self + .plans + .iter() + .find(|p| &p.plan_key == key) + .map(|p| p.name.clone()) + .unwrap_or_else(|| key.clone()), + SubscriptionType::None(_) => self + .plans + .iter() + .find(|p| p.plan_key == "free") + .map(|p| p.name.clone()) + .unwrap_or_else(|| "Free".to_string()), + } + } +} + #[derive(Debug, Deserialize, Serialize)] pub struct Feature { pub name: String, @@ -468,12 +509,12 @@ mod tests { #[test] fn billing_info_tolerates_missing_noncritical_fields() { - // Only total_credits_left and plan are required. - let r: BillingInfo = - serde_json::from_str(r#"{"total_credits_left": 500, "plan": {"name": "Premier"}}"#) - .unwrap(); + let r: BillingInfo = serde_json::from_str( + r#"{"total_credits_left":500,"subscription_type":"premier","plans":[{"plan_key":"premier","name":"Premier"}]}"#, + ) + .unwrap(); assert_eq!(r.total_credits_left, 500); - assert_eq!(r.plan.name, "Premier"); + assert_eq!(r.plan_name(), "Premier"); assert!(r.models.is_empty()); } } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 5b8bc92..4f52a81 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -230,7 +230,7 @@ async fn check_api(checks: &mut Vec, state: AuthState, chrome_avail Ok(info) => { checks.push(DoctorCheck::pass( "studio_api", - format!("reachable — plan {}", info.plan.name), + format!("reachable — plan {}", info.plan_name()), )); if info.total_credits_left > 0 { checks.push(DoctorCheck::pass( diff --git a/src/main.rs b/src/main.rs index e3608d2..a262e58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -378,12 +378,13 @@ async fn run(cli: Cli, fmt: OutputFormat) -> Result<(), CliError> { match fmt { OutputFormat::Json => output::json::success(serde_json::json!({ "authenticated": true, - "plan": info.plan.name, + "plan": info.plan_name(), "credits": info.total_credits_left, })), OutputFormat::Table => eprintln!( "Authenticated! Plan: {}, Credits: {}", - info.plan.name, info.total_credits_left + info.plan_name(), + info.total_credits_left ), } } diff --git a/src/output/table.rs b/src/output/table.rs index cfad7c4..9a8e459 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -37,17 +37,13 @@ pub fn billing(info: &BillingInfo) { .apply_modifier(UTF8_ROUND_CORNERS) .set_header(vec!["Field", "Value"]); - table.add_row(vec!["Plan", &info.plan.name]); + table.add_row(vec!["Plan", &info.plan_name()]); table.add_row(vec!["Credits Left", &info.total_credits_left.to_string()]); table.add_row(vec![ "Monthly Usage", &format!("{} / {}", info.monthly_usage, info.monthly_limit), ]); table.add_row(vec!["Active", &info.is_active.to_string()]); - table.add_row(vec!["Period", &info.period]); - if let Some(ref renew) = info.renews_on { - table.add_row(vec!["Renews On", renew]); - } println!("{table}"); }