Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ async fn main() -> anyhow::Result<()> {
user_repository: user_repo.clone(),
user_usage_repo: db.user_usage_repository()
as Arc<dyn services::user_usage::UserUsageRepository>,
agent_repo: agent_repo.clone() as Arc<dyn services::agent::ports::AgentRepository>,
stripe_secret_key: config.stripe.secret_key.clone(),
stripe_webhook_secret: config.stripe.webhook_secret.clone(),
},
Expand Down
3 changes: 3 additions & 0 deletions crates/api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions crates/api/src/routes/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -212,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 }))
Expand Down Expand Up @@ -317,6 +337,74 @@ pub async fn resume_subscription(
}))
}

/// Change the user's subscription plan
#[utoipa::path(
post,
path = "/v1/subscriptions/change",
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<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Json(req): Json<ChangePlanRequest>,
) -> Result<Json<ChangePlanResponse>, 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 switch to this plan: you have {} agent instances but this plan allows only {}. Delete excess instances to switch plans.",
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,
Expand Down Expand Up @@ -490,6 +578,7 @@ pub fn create_subscriptions_router() -> Router<AppState> {
.route("/v1/subscriptions", get(list_subscriptions))
.route("/v1/subscriptions/cancel", post(cancel_subscription))
.route("/v1/subscriptions/resume", post(resume_subscription))
.route("/v1/subscriptions/change", post(change_plan))
.route("/v1/subscriptions/portal", post(create_portal_session))
}

Expand Down
28 changes: 27 additions & 1 deletion crates/api/tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<dyn services::user_usage::UserUsageRepository>,
agent_repo: agent_repo.clone() as Arc<dyn services::agent::ports::AgentRepository>,
stripe_secret_key: config.stripe.secret_key.clone(),
stripe_webhook_secret: config.stripe.webhook_secret.clone(),
},
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -387,6 +390,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) {
Expand Down
Loading