//! Subscription tier management API for creators. use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, PriceCents, ProjectId, SubscriptionTierId}, error::{AppError, Result}, helpers::{htmx_toast_response, is_htmx_request}, types::ListResponse, validation, AppState, }; use super::verify_project_ownership; /// JSON response representing a subscription tier. #[derive(Debug, Serialize)] struct TierResponse { id: SubscriptionTierId, name: String, description: Option, price_cents: i32, is_active: bool, } /// JSON input for creating a subscription tier. #[derive(Debug, Deserialize)] pub(super) struct CreateTierRequest { pub name: String, pub description: Option, /// Price in cents. Validated non-negative on deserialization. pub price_cents: PriceCents, } /// JSON input for updating a subscription tier. #[derive(Debug, Deserialize)] pub(super) struct UpdateTierRequest { pub name: String, pub description: Option, #[serde(default)] pub is_active: bool, } /// POST /api/projects/{id}/tiers: create a subscription tier #[tracing::instrument(skip_all, name = "subscriptions::create_tier")] pub(super) async fn create_tier( State(state): State, AuthUser(user): AuthUser, Path(project_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; // Verify ownership let project = verify_project_ownership(&state, project_id, user.id).await?; // Validate input validation::validate_tier_name(&req.name)?; if let Some(ref desc) = req.description { validation::validate_tier_description(desc)?; } validation::validate_tier_price(req.price_cents.as_i32())?; // Create tier in our database let tier = db::subscriptions::create_subscription_tier( &state.db, project_id, &req.name, req.description.as_deref(), req.price_cents, ).await?; // Sandbox users get fake Stripe IDs; real users create Stripe Product + Price if user.is_sandbox { let fake_product = format!("sandbox_prod_{}", tier.id); let fake_price = format!("sandbox_price_{}", tier.id); db::subscriptions::update_tier_stripe_ids(&state.db, tier.id, &fake_product, &fake_price).await?; } else { let creator = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; let stripe_account_id = creator.stripe_account_id.as_ref() .ok_or_else(|| AppError::BadRequest("Connect your Stripe account before creating subscription tiers".to_string()))?; if !creator.stripe_charges_enabled { return Err(AppError::BadRequest("Your Stripe account is not ready for charges".to_string())); } let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let (product_id, price_id) = stripe.create_subscription_product_and_price( stripe_account_id, &req.name, req.description.as_deref(), req.price_cents.as_i32() as i64, ).await?; db::subscriptions::update_tier_stripe_ids(&state.db, tier.id, &product_id, &price_id).await?; } db::projects::bump_cache_generation(&state.db, project_id).await?; tracing::info!( "Subscription tier created: id={}, project={}, name={}, price={}", tier.id, project.slug, req.name, req.price_cents.as_i32() ); Ok(Json(TierResponse { id: tier.id, name: tier.name, description: tier.description, price_cents: tier.price_cents, is_active: tier.is_active, })) } /// GET /api/projects/{id}/tiers: list tiers for a project #[tracing::instrument(skip_all, name = "subscriptions::list_tiers")] pub(super) async fn list_tiers( State(state): State, AuthUser(user): AuthUser, Path(project_id): Path, ) -> Result { // Verify ownership (only creator can see all tiers including inactive) verify_project_ownership(&state, project_id, user.id).await?; let tiers = db::subscriptions::get_all_tiers_by_project(&state.db, project_id).await?; let data: Vec = tiers.into_iter().map(|t| TierResponse { id: t.id, name: t.name, description: t.description, price_cents: t.price_cents, is_active: t.is_active, }).collect(); Ok(Json(ListResponse { data })) } /// PUT /api/tiers/{id}: update a tier's name, description, and active status #[tracing::instrument(skip_all, name = "subscriptions::update_tier")] pub(super) async fn update_tier( State(state): State, AuthUser(user): AuthUser, Path(tier_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; // Get tier and verify project ownership let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tier_id) .await? .ok_or(AppError::NotFound)?; let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?; verify_project_ownership(&state, tier_project_id, user.id).await?; // Validate input validation::validate_tier_name(&req.name)?; if let Some(ref desc) = req.description { validation::validate_tier_description(desc)?; } let updated = db::subscriptions::update_subscription_tier( &state.db, tier_id, &req.name, req.description.as_deref(), req.is_active, ).await?; db::projects::bump_cache_generation(&state.db, tier_project_id).await?; Ok(Json(TierResponse { id: updated.id, name: updated.name, description: updated.description, price_cents: updated.price_cents, is_active: updated.is_active, })) } /// DELETE /api/tiers/{id}: soft-delete a tier (set is_active=false) #[tracing::instrument(skip_all, name = "subscriptions::delete_tier")] pub(super) async fn delete_tier( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(tier_id): Path, ) -> Result { user.check_not_suspended()?; // Get tier and verify project ownership let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tier_id) .await? .ok_or(AppError::NotFound)?; let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?; verify_project_ownership(&state, tier_project_id, user.id).await?; db::subscriptions::delete_subscription_tier(&state.db, tier_id).await?; db::projects::bump_cache_generation(&state.db, tier_project_id).await?; if is_htmx_request(&headers) { return Ok(htmx_toast_response("Tier deleted", "success").into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) }