From d58d3865098d0f6bc874e82cc7b2b676943b7770 Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 02:21:32 +0800 Subject: [PATCH 1/9] feat: switch plan --- crates/api/src/main.rs | 1 + crates/api/src/openapi.rs | 3 + crates/api/src/routes/subscriptions.rs | 83 ++++++++++++ crates/api/tests/common.rs | 5 +- crates/services/src/subscription/ports.rs | 17 +++ crates/services/src/subscription/service.rs | 138 +++++++++++++++++++- 6 files changed, 244 insertions(+), 3 deletions(-) diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 3003b907..69264997 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -210,6 +210,7 @@ async fn main() -> anyhow::Result<()> { user_repository: user_repo.clone(), user_usage_repo: db.user_usage_repository() as Arc, + agent_repo: agent_repo.clone() as Arc, stripe_secret_key: config.stripe.secret_key.clone(), stripe_webhook_secret: config.stripe.webhook_secret.clone(), }, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index e6a9f0ea..f5faebd9 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -64,6 +64,7 @@ use utoipa::OpenApi; crate::routes::subscriptions::create_portal_session, crate::routes::subscriptions::cancel_subscription, crate::routes::subscriptions::resume_subscription, + crate::routes::subscriptions::change_plan, crate::routes::subscriptions::list_plans, crate::routes::subscriptions::list_subscriptions, // Admin endpoints @@ -159,6 +160,8 @@ use utoipa::OpenApi; crate::routes::subscriptions::CreatePortalSessionResponse, crate::routes::subscriptions::CancelSubscriptionResponse, crate::routes::subscriptions::ResumeSubscriptionResponse, + crate::routes::subscriptions::ChangePlanRequest, + crate::routes::subscriptions::ChangePlanResponse, crate::routes::subscriptions::ListSubscriptionsResponse, crate::routes::subscriptions::ListPlansResponse, services::subscription::ports::SubscriptionWithPlan, diff --git a/crates/api/src/routes/subscriptions.rs b/crates/api/src/routes/subscriptions.rs index f3030861..b23f3f6f 100644 --- a/crates/api/src/routes/subscriptions.rs +++ b/crates/api/src/routes/subscriptions.rs @@ -84,6 +84,20 @@ pub struct ResumeSubscriptionResponse { pub message: String, } +/// Request to change subscription plan +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ChangePlanRequest { + /// Target plan name (e.g., "starter", "basic") + pub plan: String, +} + +/// Response for plan change +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ChangePlanResponse { + /// Success message + pub message: String, +} + /// Response containing user's subscriptions #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ListSubscriptionsResponse { @@ -317,6 +331,74 @@ pub async fn resume_subscription( })) } +/// Change the user's subscription plan +#[utoipa::path( + post, + path = "/v1/subscriptions/change-plan", + tag = "Subscriptions", + request_body = ChangePlanRequest, + responses( + (status = 200, description = "Plan changed successfully", body = ChangePlanResponse), + (status = 400, description = "Invalid plan or instance limit exceeded", body = crate::error::ApiErrorResponse), + (status = 401, description = "Unauthorized", body = crate::error::ApiErrorResponse), + (status = 404, description = "No active subscription found", body = crate::error::ApiErrorResponse), + (status = 500, description = "Internal server error", body = crate::error::ApiErrorResponse), + (status = 503, description = "Stripe not configured", body = crate::error::ApiErrorResponse) + ), + security( + ("session_token" = []) + ) +)] +pub async fn change_plan( + State(app_state): State, + Extension(user): Extension, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!( + "Changing plan for user_id={} to plan={}", + user.user_id, + req.plan + ); + + app_state + .subscription_service + .change_plan(user.user_id, req.plan.clone()) + .await + .map_err(|e| match e { + SubscriptionError::InstanceLimitExceeded { current, max } => { + ApiError::bad_request(format!( + "Cannot downgrade: you have {} agent instances but this plan allows only {}. Delete excess instances to downgrade.", + current, max + )) + } + SubscriptionError::InvalidPlan(plan) => { + ApiError::bad_request(format!("Invalid plan: {}", plan)) + } + SubscriptionError::NoActiveSubscription => { + ApiError::not_found("No active subscription found") + } + SubscriptionError::NotConfigured => { + ApiError::service_unavailable("Stripe is not configured") + } + SubscriptionError::DatabaseError(msg) => { + tracing::error!(error = ?msg, "Database error changing plan"); + ApiError::internal_server_error("Failed to change plan") + } + SubscriptionError::StripeError(msg) => { + tracing::error!(error = ?msg, "Stripe error changing plan"); + ApiError::internal_server_error("Failed to change plan") + } + _ => { + tracing::error!(error = ?e, "Failed to change plan"); + ApiError::internal_server_error("Failed to change plan") + } + })?; + + Ok(Json(ChangePlanResponse { + message: "Plan changed successfully".to_string(), + })) +} + /// Get available subscription plans #[utoipa::path( get, @@ -490,6 +572,7 @@ pub fn create_subscriptions_router() -> Router { .route("/v1/subscriptions", get(list_subscriptions)) .route("/v1/subscriptions/cancel", post(cancel_subscription)) .route("/v1/subscriptions/resume", post(resume_subscription)) + .route("/v1/subscriptions/change-plan", post(change_plan)) .route("/v1/subscriptions/portal", post(create_portal_session)) } diff --git a/crates/api/tests/common.rs b/crates/api/tests/common.rs index 5631976a..99ff747d 100644 --- a/crates/api/tests/common.rs +++ b/crates/api/tests/common.rs @@ -123,6 +123,9 @@ pub async fn create_test_server_and_db( ), ); + // Create agent repo (needed by subscription service for change_plan) + let agent_repo = db.agent_repository(); + // Initialize subscription service for testing let subscription_service = Arc::new(services::subscription::SubscriptionServiceImpl::new( services::subscription::SubscriptionServiceConfig { @@ -138,6 +141,7 @@ pub async fn create_test_server_and_db( user_repository: user_repo.clone(), user_usage_repo: db.user_usage_repository() as Arc, + agent_repo: agent_repo.clone() as Arc, stripe_secret_key: config.stripe.secret_key.clone(), stripe_webhook_secret: config.stripe.webhook_secret.clone(), }, @@ -221,7 +225,6 @@ pub async fn create_test_server_and_db( ); // Create agent service for testing - let agent_repo = db.agent_repository(); let agent_service = Arc::new(services::agent::AgentServiceImpl::new( agent_repo.clone(), config.agent.managers.clone(), diff --git a/crates/services/src/subscription/ports.rs b/crates/services/src/subscription/ports.rs index 30954ffe..b6f70639 100644 --- a/crates/services/src/subscription/ports.rs +++ b/crates/services/src/subscription/ports.rs @@ -78,6 +78,8 @@ pub enum SubscriptionError { NoActiveSubscription, /// Monthly token limit exceeded (used >= limit) MonthlyTokenLimitExceeded { used: i64, limit: u64 }, + /// Cannot downgrade: current instance count exceeds target plan's limit + InstanceLimitExceeded { current: u64, max: u64 }, /// Subscription is not scheduled for cancellation (cannot resume) SubscriptionNotScheduledForCancellation, /// User has no Stripe customer record @@ -109,6 +111,13 @@ impl fmt::Display for SubscriptionError { used, limit ) } + Self::InstanceLimitExceeded { current, max } => { + write!( + f, + "Cannot downgrade: you have {} agent instances but this plan allows only {}", + current, max + ) + } Self::SubscriptionNotScheduledForCancellation => { write!(f, "Subscription is not scheduled for cancellation") } @@ -233,6 +242,14 @@ pub trait SubscriptionService: Send + Sync { /// Resume a subscription that was scheduled to cancel at period end async fn resume_subscription(&self, user_id: UserId) -> Result<(), SubscriptionError>; + /// Change the user's subscription to a different plan. + /// Validates that the user's active instance count does not exceed the target plan's limit. + async fn change_plan( + &self, + user_id: UserId, + target_plan: String, + ) -> Result<(), SubscriptionError>; + /// Get subscriptions for a user with plan names resolved /// If active_only is true, returns only active (not expired) subscriptions async fn get_user_subscriptions( diff --git a/crates/services/src/subscription/service.rs b/crates/services/src/subscription/service.rs index 49780e1e..6395178d 100644 --- a/crates/services/src/subscription/service.rs +++ b/crates/services/src/subscription/service.rs @@ -2,6 +2,7 @@ use super::ports::{ PaymentWebhookRepository, StripeCustomerRepository, Subscription, SubscriptionError, SubscriptionPlan, SubscriptionRepository, SubscriptionService, SubscriptionWithPlan, }; +use crate::agent::ports::AgentRepository; use crate::system_configs::ports::{SubscriptionPlanConfig, SystemConfigsService}; use crate::user::ports::UserRepository; use crate::user_usage::ports::UserUsageRepository; @@ -14,8 +15,8 @@ use std::time::Instant; use stripe::{ BillingPortalSession, CheckoutSession, CheckoutSessionMode, Client, CreateBillingPortalSession, CreateCheckoutSession, CreateCheckoutSessionLineItems, CreateCheckoutSessionSubscriptionData, - Customer, CustomerId, RequestStrategy, Subscription as StripeSubscription, Webhook, - WebhookError, + Customer, CustomerId, RequestStrategy, Subscription as StripeSubscription, + UpdateSubscriptionItems, Webhook, WebhookError, }; use tokio::sync::RwLock; @@ -28,6 +29,7 @@ pub struct SubscriptionServiceConfig { pub system_configs_service: Arc, pub user_repository: Arc, pub user_usage_repo: Arc, + pub agent_repo: Arc, pub stripe_secret_key: String, pub stripe_webhook_secret: String, } @@ -50,6 +52,7 @@ pub struct SubscriptionServiceImpl { system_configs_service: Arc, user_repository: Arc, user_usage_repo: Arc, + agent_repo: Arc, stripe_secret_key: String, stripe_webhook_secret: String, token_limit_cache: Arc>>, @@ -65,6 +68,7 @@ impl SubscriptionServiceImpl { system_configs_service: config.system_configs_service, user_repository: config.user_repository, user_usage_repo: config.user_usage_repo, + agent_repo: config.agent_repo, stripe_secret_key: config.stripe_secret_key, stripe_webhook_secret: config.stripe_webhook_secret, token_limit_cache: Arc::new(RwLock::new(HashMap::new())), @@ -625,6 +629,136 @@ impl SubscriptionService for SubscriptionServiceImpl { Ok(()) } + async fn change_plan( + &self, + user_id: UserId, + target_plan: String, + ) -> Result<(), SubscriptionError> { + tracing::info!( + "Changing plan for user_id={} to plan={}", + user_id, + target_plan + ); + + // Get provider plans (stripe) + let provider_plans = self.get_plans_for_provider("stripe").await?; + let price_id = provider_plans + .get(&target_plan) + .cloned() + .ok_or_else(|| SubscriptionError::InvalidPlan(target_plan.clone()))?; + + // Get target plan config for agent_instances limit + let configs = self + .system_configs_service + .get_configs() + .await + .map_err(|e| SubscriptionError::InternalError(e.to_string()))?; + let plan_config = configs + .and_then(|c| c.subscription_plans) + .and_then(|plans| plans.get(&target_plan).cloned()); + let max_instances = plan_config + .and_then(|c| c.agent_instances) + .map(|l| l.max) + .unwrap_or(u64::MAX); + + // Validate instance count + let instance_count = + self.agent_repo + .count_user_instances(user_id) + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))? as u64; + if instance_count > max_instances { + return Err(SubscriptionError::InstanceLimitExceeded { + current: instance_count, + max: max_instances, + }); + } + + // Get active subscription + let subscription = self + .subscription_repo + .get_active_subscription(user_id) + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))? + .ok_or(SubscriptionError::NoActiveSubscription)?; + + // Don't change if already on target plan + if subscription.price_id == price_id { + tracing::info!( + "User already on target plan: user_id={}, plan={}", + user_id, + target_plan + ); + return Ok(()); + } + + // Retrieve current Stripe subscription to get subscription item ID + let client = self.get_stripe_client(); + let subscription_id: stripe::SubscriptionId = subscription + .subscription_id + .parse() + .map_err(|_| SubscriptionError::StripeError("Invalid subscription ID".into()))?; + + let stripe_sub = StripeSubscription::retrieve(&client, &subscription_id, &[]) + .await + .map_err(|e| SubscriptionError::StripeError(e.to_string()))?; + + let subscription_item_id = stripe_sub + .items + .data + .first() + .map(|item| item.id.to_string()) + .ok_or_else(|| SubscriptionError::StripeError("No subscription item found".into()))?; + + // Update subscription to new price + let update_item = UpdateSubscriptionItems { + id: Some(subscription_item_id), + price: Some(price_id.clone()), + ..Default::default() + }; + let params = stripe::UpdateSubscription { + items: Some(vec![update_item]), + ..Default::default() + }; + + let updated_sub = StripeSubscription::update(&client, &subscription_id, params) + .await + .map_err(|e| SubscriptionError::StripeError(e.to_string()))?; + + // Update database + let updated_model = + self.stripe_subscription_to_model(&updated_sub, user_id, &subscription.provider)?; + let mut db_client = self + .db_pool + .get() + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?; + let txn = db_client + .transaction() + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?; + + self.subscription_repo + .upsert_subscription(&txn, updated_model) + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?; + + self.invalidate_token_limit_cache(user_id).await; + + txn.commit() + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))?; + + tracing::info!( + "Plan changed: user_id={}, target_plan={}, subscription_id={}", + user_id, + target_plan, + subscription.subscription_id + ); + + Ok(()) + } + async fn get_user_subscriptions( &self, user_id: UserId, From 072255d08d14a221653cc96e1b7f3eea4fa088fa Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 13:19:44 +0800 Subject: [PATCH 2/9] fix: clippy --- crates/api/src/routes/subscriptions.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/api/src/routes/subscriptions.rs b/crates/api/src/routes/subscriptions.rs index b23f3f6f..8380abd7 100644 --- a/crates/api/src/routes/subscriptions.rs +++ b/crates/api/src/routes/subscriptions.rs @@ -226,6 +226,12 @@ pub async fn create_subscription( tracing::error!("Unexpected MonthlyTokenLimitExceeded in create"); ApiError::internal_server_error("Failed to create subscription") } + SubscriptionError::InstanceLimitExceeded { current, max } => { + ApiError::bad_request(format!( + "Cannot subscribe: current instance count ({}) exceeds plan limit ({})", + current, max + )) + } })?; Ok(Json(CreateSubscriptionResponse { checkout_url })) From d47665d78fde0d9976e1d94223f7c0b703440b6b Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 18:04:08 +0800 Subject: [PATCH 3/9] fix: resolve comments --- crates/api/src/routes/subscriptions.rs | 6 ++-- crates/services/src/subscription/ports.rs | 4 +-- crates/services/src/subscription/service.rs | 36 ++++++++++----------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/api/src/routes/subscriptions.rs b/crates/api/src/routes/subscriptions.rs index 8380abd7..06d7c959 100644 --- a/crates/api/src/routes/subscriptions.rs +++ b/crates/api/src/routes/subscriptions.rs @@ -340,7 +340,7 @@ pub async fn resume_subscription( /// Change the user's subscription plan #[utoipa::path( post, - path = "/v1/subscriptions/change-plan", + path = "/v1/subscriptions/change", tag = "Subscriptions", request_body = ChangePlanRequest, responses( @@ -373,7 +373,7 @@ pub async fn change_plan( .map_err(|e| match e { SubscriptionError::InstanceLimitExceeded { current, max } => { ApiError::bad_request(format!( - "Cannot downgrade: you have {} agent instances but this plan allows only {}. Delete excess instances to downgrade.", + "Cannot switch to this plan: you have {} agent instances but this plan allows only {}. Delete excess instances to switch plans.", current, max )) } @@ -578,7 +578,7 @@ pub fn create_subscriptions_router() -> Router { .route("/v1/subscriptions", get(list_subscriptions)) .route("/v1/subscriptions/cancel", post(cancel_subscription)) .route("/v1/subscriptions/resume", post(resume_subscription)) - .route("/v1/subscriptions/change-plan", post(change_plan)) + .route("/v1/subscriptions/change", post(change_plan)) .route("/v1/subscriptions/portal", post(create_portal_session)) } diff --git a/crates/services/src/subscription/ports.rs b/crates/services/src/subscription/ports.rs index b6f70639..356d0366 100644 --- a/crates/services/src/subscription/ports.rs +++ b/crates/services/src/subscription/ports.rs @@ -78,7 +78,7 @@ pub enum SubscriptionError { NoActiveSubscription, /// Monthly token limit exceeded (used >= limit) MonthlyTokenLimitExceeded { used: i64, limit: u64 }, - /// Cannot downgrade: current instance count exceeds target plan's limit + /// Cannot switch to plan: current instance count exceeds target plan's limit InstanceLimitExceeded { current: u64, max: u64 }, /// Subscription is not scheduled for cancellation (cannot resume) SubscriptionNotScheduledForCancellation, @@ -114,7 +114,7 @@ impl fmt::Display for SubscriptionError { Self::InstanceLimitExceeded { current, max } => { write!( f, - "Cannot downgrade: you have {} agent instances but this plan allows only {}", + "Cannot switch to this plan: you have {} agent instances but this plan allows only {}", current, max ) } diff --git a/crates/services/src/subscription/service.rs b/crates/services/src/subscription/service.rs index 6395178d..966d2d09 100644 --- a/crates/services/src/subscription/service.rs +++ b/crates/services/src/subscription/service.rs @@ -647,6 +647,24 @@ impl SubscriptionService for SubscriptionServiceImpl { .cloned() .ok_or_else(|| SubscriptionError::InvalidPlan(target_plan.clone()))?; + // Get active subscription first (fail fast before instance count validation) + let subscription = self + .subscription_repo + .get_active_subscription(user_id) + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))? + .ok_or(SubscriptionError::NoActiveSubscription)?; + + // Don't change if already on target plan + if subscription.price_id == price_id { + tracing::info!( + "User already on target plan: user_id={}, plan={}", + user_id, + target_plan + ); + return Ok(()); + } + // Get target plan config for agent_instances limit let configs = self .system_configs_service @@ -674,24 +692,6 @@ impl SubscriptionService for SubscriptionServiceImpl { }); } - // Get active subscription - let subscription = self - .subscription_repo - .get_active_subscription(user_id) - .await - .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))? - .ok_or(SubscriptionError::NoActiveSubscription)?; - - // Don't change if already on target plan - if subscription.price_id == price_id { - tracing::info!( - "User already on target plan: user_id={}, plan={}", - user_id, - target_plan - ); - return Ok(()); - } - // Retrieve current Stripe subscription to get subscription item ID let client = self.get_stripe_client(); let subscription_id: stripe::SubscriptionId = subscription From ce77ba98bab3e113ab3fc53fa425afd989bd1984 Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 18:16:39 +0800 Subject: [PATCH 4/9] test: change plan --- crates/api/tests/common.rs | 23 +++ crates/api/tests/subscriptions_tests.rs | 204 +++++++++++++++++++++++- 2 files changed, 225 insertions(+), 2 deletions(-) diff --git a/crates/api/tests/common.rs b/crates/api/tests/common.rs index 99ff747d..a9fafedf 100644 --- a/crates/api/tests/common.rs +++ b/crates/api/tests/common.rs @@ -389,6 +389,29 @@ pub async fn insert_test_subscription( .expect("insert subscription"); } +/// Insert agent instances for a user (for testing instance limit validation). +/// Count is used by count_user_instances; instances must have status != 'deleted'. +pub async fn insert_test_agent_instances(db: &database::Database, user_email: &str, count: usize) { + let user = db + .user_repository() + .get_user_by_email(user_email) + .await + .expect("get user") + .expect("user must exist"); + + let client = db.pool().get().await.expect("get pool client"); + for i in 0..count { + let instance_id = format!("inst_test_{}_{}", Uuid::new_v4(), i); + client + .execute( + "INSERT INTO agent_instances (user_id, instance_id, name, type) VALUES ($1, $2, $3, $4)", + &[&user.id, &instance_id, &format!("Test Instance {}", i), &"openclaw"], + ) + .await + .expect("insert agent instance"); + } +} + /// Clean up all subscriptions for a user (by email). /// Useful for test isolation to ensure no leftover data from previous test runs. pub async fn cleanup_user_subscriptions(db: &database::Database, user_email: &str) { diff --git a/crates/api/tests/subscriptions_tests.rs b/crates/api/tests/subscriptions_tests.rs index dfaa8921..841b8788 100644 --- a/crates/api/tests/subscriptions_tests.rs +++ b/crates/api/tests/subscriptions_tests.rs @@ -4,8 +4,8 @@ use api::routes::api::SUBSCRIPTION_REQUIRED_ERROR_MESSAGE; use chrono::Duration; use common::{ cleanup_user_subscriptions, clear_subscription_plans, create_test_server, - create_test_server_and_db, insert_test_subscription, mock_login, set_subscription_plans, - TestServerConfig, + create_test_server_and_db, insert_test_agent_instances, insert_test_subscription, mock_login, + set_subscription_plans, TestServerConfig, }; use serde_json::json; use serial_test::serial; @@ -383,6 +383,206 @@ async fn test_resume_subscription_not_scheduled_for_cancellation() { ); } +// --- Change plan tests --- + +#[tokio::test] +#[serial(subscription_tests)] +async fn test_change_plan_requires_auth() { + let server = create_test_server().await; + + let request_body = json!({ "plan": "pro" }); + + // POST /v1/subscriptions/change without authentication should return 401 + let response = server + .post("/v1/subscriptions/change") + .json(&request_body) + .await; + + assert_eq!( + response.status_code(), + 401, + "POST /v1/subscriptions/change should require authentication" + ); +} + +#[tokio::test] +#[serial(subscription_tests)] +async fn test_change_plan_no_active_subscription() { + let server = create_test_server().await; + + set_subscription_plans( + &server, + json!({ + "basic": { "providers": { "stripe": { "price_id": "price_test_basic" } }, "agent_instances": { "max": 1 }, "monthly_tokens": { "max": 1000000 } }, + "pro": { "providers": { "stripe": { "price_id": "price_test_pro" } }, "agent_instances": { "max": 5 }, "monthly_tokens": { "max": 1000000 } } + }), + ) + .await; + + let user_email = "test_change_plan_no_sub@example.com"; + let user_token = mock_login(&server, user_email).await; + + let request_body = json!({ "plan": "pro" }); + + let response = server + .post("/v1/subscriptions/change") + .add_header( + http::HeaderName::from_static("authorization"), + http::HeaderValue::from_str(&format!("Bearer {user_token}")).unwrap(), + ) + .add_header( + http::HeaderName::from_static("content-type"), + http::HeaderValue::from_static("application/json"), + ) + .json(&request_body) + .await; + + assert_eq!( + response.status_code(), + 404, + "Should return 404 when no active subscription exists" + ); +} + +#[tokio::test] +#[serial(subscription_tests)] +async fn test_change_plan_invalid_plan() { + let (server, db) = create_test_server_and_db(TestServerConfig::default()).await; + + set_subscription_plans( + &server, + json!({ + "basic": { "providers": { "stripe": { "price_id": "price_test_basic" } }, "agent_instances": { "max": 1 }, "monthly_tokens": { "max": 1000000 } }, + "pro": { "providers": { "stripe": { "price_id": "price_test_pro" } }, "agent_instances": { "max": 5 }, "monthly_tokens": { "max": 1000000 } } + }), + ) + .await; + + let user_email = "test_change_plan_invalid@example.com"; + cleanup_user_subscriptions(&db, user_email).await; + insert_test_subscription(&server, &db, user_email, false).await; + let user_token = mock_login(&server, user_email).await; + + let request_body = json!({ "plan": "nonexistent_plan" }); + + let response = server + .post("/v1/subscriptions/change") + .add_header( + http::HeaderName::from_static("authorization"), + http::HeaderValue::from_str(&format!("Bearer {user_token}")).unwrap(), + ) + .add_header( + http::HeaderName::from_static("content-type"), + http::HeaderValue::from_static("application/json"), + ) + .json(&request_body) + .await; + + assert_eq!( + response.status_code(), + 400, + "Should return 400 for invalid plan" + ); +} + +#[tokio::test] +#[serial(subscription_tests)] +async fn test_change_plan_instance_limit_exceeded() { + let (server, db) = create_test_server_and_db(TestServerConfig::default()).await; + + set_subscription_plans( + &server, + json!({ + "basic": { "providers": { "stripe": { "price_id": "price_test_basic" } }, "agent_instances": { "max": 5 }, "monthly_tokens": { "max": 1000000 } }, + "starter": { "providers": { "stripe": { "price_id": "price_test_starter" } }, "agent_instances": { "max": 1 }, "monthly_tokens": { "max": 1000000 } } + }), + ) + .await; + + let user_email = "test_change_plan_instance_limit@example.com"; + cleanup_user_subscriptions(&db, user_email).await; + insert_test_subscription(&server, &db, user_email, false).await; + insert_test_agent_instances(&db, user_email, 2).await; + let user_token = mock_login(&server, user_email).await; + + // User has 2 instances and basic plan (max 5); trying to switch to starter (max 1) + let request_body = json!({ "plan": "starter" }); + + let response = server + .post("/v1/subscriptions/change") + .add_header( + http::HeaderName::from_static("authorization"), + http::HeaderValue::from_str(&format!("Bearer {user_token}")).unwrap(), + ) + .add_header( + http::HeaderName::from_static("content-type"), + http::HeaderValue::from_static("application/json"), + ) + .json(&request_body) + .await; + + assert_eq!( + response.status_code(), + 400, + "Should return 400 when instance count exceeds target plan limit" + ); + + let body: serde_json::Value = response.json(); + let detail = body.get("detail").and_then(|v| v.as_str()).unwrap_or(""); + assert!( + detail.contains("2") && detail.contains("1"), + "Error message should include current (2) and max (1) instance counts, got: {}", + detail + ); +} + +#[tokio::test] +#[serial(subscription_tests)] +async fn test_change_plan_success_validates_before_stripe() { + let (server, db) = create_test_server_and_db(TestServerConfig::default()).await; + + // User on basic (price_test_basic), changing to pro (price_test_pro) + set_subscription_plans( + &server, + json!({ + "basic": { "providers": { "stripe": { "price_id": "price_test_basic" } }, "agent_instances": { "max": 5 }, "monthly_tokens": { "max": 1000000 } }, + "pro": { "providers": { "stripe": { "price_id": "price_test_pro" } }, "agent_instances": { "max": 5 }, "monthly_tokens": { "max": 1000000 } } + }), + ) + .await; + + let user_email = "test_change_plan_success@example.com"; + cleanup_user_subscriptions(&db, user_email).await; + insert_test_subscription(&server, &db, user_email, false).await; + // 0 instances - under both plans' limits + let user_token = mock_login(&server, user_email).await; + + let request_body = json!({ "plan": "pro" }); + + let response = server + .post("/v1/subscriptions/change") + .add_header( + http::HeaderName::from_static("authorization"), + http::HeaderValue::from_str(&format!("Bearer {user_token}")).unwrap(), + ) + .add_header( + http::HeaderName::from_static("content-type"), + http::HeaderValue::from_static("application/json"), + ) + .json(&request_body) + .await; + + // Validation passes (auth, subscription, valid plan, instance limit). + // Stripe API call fails with fake sub_test_* ID -> 500. + // This confirms we reach the Stripe update step; full success would need Stripe mocking. + let status = response.status_code(); + assert!( + status == 200 || status == 500, + "Should pass validation (200) or fail at Stripe (500), got {}", + status + ); +} + #[tokio::test] #[serial(subscription_tests)] async fn test_list_subscriptions_successfully() { From f1c2501b34e21af73eb07fd154614624056354d8 Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 18:28:28 +0800 Subject: [PATCH 5/9] test: fix test failure --- crates/api/tests/subscriptions_tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/api/tests/subscriptions_tests.rs b/crates/api/tests/subscriptions_tests.rs index 841b8788..4e33054e 100644 --- a/crates/api/tests/subscriptions_tests.rs +++ b/crates/api/tests/subscriptions_tests.rs @@ -528,11 +528,15 @@ async fn test_change_plan_instance_limit_exceeded() { ); let body: serde_json::Value = response.json(); - let detail = body.get("detail").and_then(|v| v.as_str()).unwrap_or(""); + let message = body + .get("message") + .and_then(|v| v.as_str()) + .or_else(|| body.get("detail").and_then(|v| v.as_str())) + .unwrap_or(""); assert!( - detail.contains("2") && detail.contains("1"), + message.contains("2") && message.contains("1"), "Error message should include current (2) and max (1) instance counts, got: {}", - detail + message ); } From 14667a35c84f20ab668e698c784f28d6654228fa Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 19:49:00 +0800 Subject: [PATCH 6/9] fix: explicit subscription proration --- crates/services/src/subscription/service.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/services/src/subscription/service.rs b/crates/services/src/subscription/service.rs index 966d2d09..b2d4e836 100644 --- a/crates/services/src/subscription/service.rs +++ b/crates/services/src/subscription/service.rs @@ -718,6 +718,9 @@ impl SubscriptionService for SubscriptionServiceImpl { }; let params = stripe::UpdateSubscription { items: Some(vec![update_item]), + proration_behavior: Some( + stripe::generated::billing::subscription::SubscriptionProrationBehavior::CreateProrations, + ), ..Default::default() }; From 7743e2b68fee37a7fdcd7f35a3318596291b6b82 Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 20:22:57 +0800 Subject: [PATCH 7/9] fix: check instance count before create suscription --- crates/api/tests/subscriptions_tests.rs | 59 +++++++++++++++++++++ crates/services/src/subscription/service.rs | 29 ++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/api/tests/subscriptions_tests.rs b/crates/api/tests/subscriptions_tests.rs index 4e33054e..f0f33b93 100644 --- a/crates/api/tests/subscriptions_tests.rs +++ b/crates/api/tests/subscriptions_tests.rs @@ -268,6 +268,65 @@ async fn test_create_subscription_invalid_plan() { ); } +#[tokio::test] +#[serial(subscription_tests)] +async fn test_create_subscription_instance_limit_exceeded() { + let (server, db) = create_test_server_and_db(TestServerConfig::default()).await; + ensure_stripe_env_for_gating(); + + set_subscription_plans( + &server, + json!({ + "starter": { "providers": { "stripe": { "price_id": "price_test_starter" } }, "agent_instances": { "max": 1 }, "monthly_tokens": { "max": 1000000 } }, + "pro": { "providers": { "stripe": { "price_id": "price_test_pro" } }, "agent_instances": { "max": 5 }, "monthly_tokens": { "max": 1000000 } } + }), + ) + .await; + + // User had Pro (5 instances), cancelled; instances remain. Resubscribing to Starter (max 1). + let user_email = "test_create_instance_limit@example.com"; + cleanup_user_subscriptions(&db, user_email).await; + insert_test_agent_instances(&db, user_email, 3).await; + let user_token = mock_login(&server, user_email).await; + + let request_body = json!({ + "plan": "starter", + "success_url": "https://example.com/success", + "cancel_url": "https://example.com/cancel" + }); + + let response = server + .post("/v1/subscriptions") + .add_header( + http::HeaderName::from_static("authorization"), + http::HeaderValue::from_str(&format!("Bearer {user_token}")).unwrap(), + ) + .add_header( + http::HeaderName::from_static("content-type"), + http::HeaderValue::from_static("application/json"), + ) + .json(&request_body) + .await; + + assert_eq!( + response.status_code(), + 400, + "Should return 400 when instance count exceeds plan limit before checkout" + ); + + let body: serde_json::Value = response.json(); + let message = body + .get("message") + .and_then(|v| v.as_str()) + .or_else(|| body.get("detail").and_then(|v| v.as_str())) + .unwrap_or(""); + assert!( + message.contains("3") && message.contains("1"), + "Error message should include current (3) and max (1) instance counts, got: {}", + message + ); +} + #[tokio::test] #[serial(subscription_tests)] async fn test_cancel_subscription_requires_auth() { diff --git a/crates/services/src/subscription/service.rs b/crates/services/src/subscription/service.rs index b2d4e836..5fe8d4f9 100644 --- a/crates/services/src/subscription/service.rs +++ b/crates/services/src/subscription/service.rs @@ -434,12 +434,35 @@ impl SubscriptionService for SubscriptionServiceImpl { .ok_or_else(|| SubscriptionError::InvalidPlan(plan.clone()))? .clone(); - // Fetch trial_period_days from subscription plan config - let trial_period_days = self + // Validate instance count: user may have leftover instances from a prior higher-tier plan. + // Fail before checkout to avoid subscribing to a lower-tier plan they cannot use. + let configs = self .system_configs_service .get_configs() .await - .map_err(|e| SubscriptionError::InternalError(e.to_string()))? + .map_err(|e| SubscriptionError::InternalError(e.to_string()))?; + let plan_config = configs + .and_then(|c| c.subscription_plans) + .and_then(|plans| plans.get(&plan).cloned()); + let max_instances = plan_config + .and_then(|c| c.agent_instances) + .map(|l| l.max) + .unwrap_or(u64::MAX); + + let instance_count = + self.agent_repo + .count_user_instances(user_id) + .await + .map_err(|e| SubscriptionError::DatabaseError(e.to_string()))? as u64; + if instance_count > max_instances { + return Err(SubscriptionError::InstanceLimitExceeded { + current: instance_count, + max: max_instances, + }); + } + + // Fetch trial_period_days from subscription plan config (reuse configs from instance check) + let trial_period_days = configs .and_then(|c| c.subscription_plans) .and_then(|plans| plans.get(&plan).cloned()) .and_then(|p| p.trial_period_days) From 682d4a9fe5237b522447c0816d6ee002569020d9 Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 20:26:26 +0800 Subject: [PATCH 8/9] fix: build failure --- crates/services/src/subscription/service.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/services/src/subscription/service.rs b/crates/services/src/subscription/service.rs index 5fe8d4f9..93631709 100644 --- a/crates/services/src/subscription/service.rs +++ b/crates/services/src/subscription/service.rs @@ -445,7 +445,8 @@ impl SubscriptionService for SubscriptionServiceImpl { .and_then(|c| c.subscription_plans) .and_then(|plans| plans.get(&plan).cloned()); let max_instances = plan_config - .and_then(|c| c.agent_instances) + .as_ref() + .and_then(|c| c.agent_instances.as_ref()) .map(|l| l.max) .unwrap_or(u64::MAX); @@ -461,10 +462,8 @@ impl SubscriptionService for SubscriptionServiceImpl { }); } - // Fetch trial_period_days from subscription plan config (reuse configs from instance check) - let trial_period_days = configs - .and_then(|c| c.subscription_plans) - .and_then(|plans| plans.get(&plan).cloned()) + // Fetch trial_period_days from subscription plan config (reuse plan_config from instance check) + let trial_period_days = plan_config .and_then(|p| p.trial_period_days) // Stripe supports a maximum trial period of 730 days .filter(|&n| n > 0 && n <= 730); From f6bb0801c5ea119c3f208913bc196092b9c98a18 Mon Sep 17 00:00:00 2001 From: Robert Yan Date: Mon, 23 Feb 2026 20:39:05 +0800 Subject: [PATCH 9/9] test: fix test failure --- crates/api/tests/subscriptions_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/api/tests/subscriptions_tests.rs b/crates/api/tests/subscriptions_tests.rs index f0f33b93..dabbd391 100644 --- a/crates/api/tests/subscriptions_tests.rs +++ b/crates/api/tests/subscriptions_tests.rs @@ -285,9 +285,9 @@ async fn test_create_subscription_instance_limit_exceeded() { // User had Pro (5 instances), cancelled; instances remain. Resubscribing to Starter (max 1). let user_email = "test_create_instance_limit@example.com"; + let user_token = mock_login(&server, user_email).await; cleanup_user_subscriptions(&db, user_email).await; insert_test_agent_instances(&db, user_email, 3).await; - let user_token = mock_login(&server, user_email).await; let request_body = json!({ "plan": "starter",