//! Subscription checkout handlers: Fan+, creator tiers, and project subscriptions. use axum::{ extract::{Path, State}, response::{IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; use crate::{ auth::AuthUser, db::{self, CodePurpose, PromoCodeId, SubscriptionTierId}, error::{AppError, Result, ResultExt}, AppState, }; /// POST /stripe/fan-plus: Create a Fan+ subscription checkout and redirect #[tracing::instrument(skip_all, name = "stripe::fan_plus_checkout")] pub(in crate::routes::stripe) async fn create_fan_plus_checkout( State(state): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; // Check Fan+ price is configured let price_id = state.config.fan_plus_price_id.as_ref() .ok_or_else(|| AppError::BadRequest("Fan+ is not configured".to_string()))?; // Check not already a Fan+ subscriber if db::fan_plus::is_fan_plus_active(&state.db, user.id).await? { return Ok(Redirect::to("/fan-plus").into_response()); } let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let success_url = format!("{}/fan-plus?subscribed=true", state.config.host_url); let cancel_url = format!("{}/fan-plus", state.config.host_url); let session = stripe.create_fan_plus_checkout_session( price_id, user.id, &success_url, &cancel_url, ).await?; let checkout_url = session.url .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; Ok(Redirect::to(&checkout_url).into_response()) } /// Reject the request if its `Sec-Fetch-Site` doesn't look like a real /// click from our own dashboard. /// /// These two endpoints (`fan-plus/cancel` and `resume`) are exempted from /// the global CSRF middleware because they're vanilla form posts that /// redirect back to the dashboard — there's no place to attach a header /// token. SameSite=Lax cookies block cross-site form posts already, but /// Sec-Fetch-Site is the explicit second check we promise in the /// CSRF-exempt rationale (see `csrf.rs`). /// /// Allow: /// - `same-origin` — click from our own dashboard, exactly what we want /// - missing — older browsers that don't send the header at all /// /// Reject everything else (`cross-site`, `same-site`, `none`/typed-URL). fn check_sec_fetch_site(headers: &axum::http::HeaderMap) -> Result<()> { let Some(value) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) else { return Ok(()); }; if value == "same-origin" { return Ok(()); } tracing::warn!(sec_fetch_site = value, "fan-plus subscription change rejected: bad Sec-Fetch-Site"); Err(AppError::Forbidden) } /// POST /stripe/fan-plus/cancel: Schedule Fan+ to cancel at period end. /// /// Self-service: leaves the subscription active through the current paid /// period (no proration). The user can resume before period end to undo. /// Stripe's `customer.subscription.updated` webhook keeps the local flag in /// sync if the user later cancels via the customer portal instead. #[tracing::instrument(skip_all, name = "stripe::fan_plus_cancel")] pub(in crate::routes::stripe) async fn cancel_fan_plus( State(state): State, headers: axum::http::HeaderMap, AuthUser(user): AuthUser, ) -> Result { check_sec_fetch_site(&headers)?; let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id) .await? .ok_or_else(|| AppError::BadRequest("No active Fan+ subscription".to_string()))?; let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; stripe .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, true) .await?; db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, true).await?; Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+cancellation+scheduled")) } /// POST /stripe/fan-plus/resume: Undo a scheduled cancellation. #[tracing::instrument(skip_all, name = "stripe::fan_plus_resume")] pub(in crate::routes::stripe) async fn resume_fan_plus( State(state): State, headers: axum::http::HeaderMap, AuthUser(user): AuthUser, ) -> Result { check_sec_fetch_site(&headers)?; let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id) .await? .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?; let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; stripe .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, false) .await?; db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, false).await?; Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+resumed")) } /// POST /stripe/billing-portal: Open the Stripe customer portal. /// /// Stripe-hosted: handles payment method updates, invoice history, and /// (if configured in the dashboard) subscription cancellation. Routes from /// the dashboard Fan+ pane. #[tracing::instrument(skip_all, name = "stripe::billing_portal")] pub(in crate::routes::stripe) async fn open_billing_portal( State(state): State, AuthUser(user): AuthUser, ) -> Result { let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id) .await? .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?; let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let return_url = format!("{}/dashboard?tab=account", state.config.host_url); let url = stripe .create_billing_portal_session(&sub.stripe_customer_id, &return_url) .await?; Ok(Redirect::to(&url)) } /// Form data for creator tier checkout. #[derive(Debug, Deserialize)] pub(in crate::routes::stripe) struct CreatorTierForm { tier: String, /// Billing cadence: "monthly" or "annual". Defaults to "monthly" when /// absent so older clients and the existing form post (no interval input) /// keep working. #[serde(default)] interval: Option, } /// Billing cadence requested by the checkout form. We try (founder|sticker) /// × (annual|monthly) in priority order and fall back to the closest /// configured price rather than erroring on a missing combination. #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum BillingInterval { Monthly, Annual, } impl BillingInterval { fn from_form(s: Option<&str>) -> Self { match s.unwrap_or("monthly") { "annual" | "yearly" | "year" => Self::Annual, _ => Self::Monthly, } } } /// POST /stripe/creator-tier: Create a creator tier subscription checkout and redirect #[tracing::instrument(skip_all, name = "stripe::creator_tier_checkout")] pub(in crate::routes::stripe) async fn create_creator_tier_checkout( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; // Parse and validate the tier let tier: db::CreatorTier = form.tier.parse() .map_err(|_| AppError::BadRequest("Invalid tier".to_string()))?; // Pick the price ID across two axes: (founder vs sticker) × (annual vs // monthly). Founder applies when the window is open OR this account is // already locked in. We try the requested combination, then degrade // gracefully toward more conservative options rather than erroring on a // missing env var: // // founder + annual → founder + monthly → sticker + annual → sticker + monthly // // This lets us roll founder/annual prices out per-tier without breaking // checkout if one env var hasn't been set yet. let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; let founder_eligible = state.config.creator_founder_window_open || db_user.is_founder_locked(); let interval = BillingInterval::from_form(form.interval.as_deref()); let founder_annual = state.config.creator_tier_founder_annual_prices.get(&tier); let founder_monthly = state.config.creator_tier_founder_prices.get(&tier); let sticker_annual = state.config.creator_tier_annual_prices.get(&tier); let sticker_monthly = state.config.creator_tier_prices.get(&tier); let price_id = match (founder_eligible, interval) { (true, BillingInterval::Annual) => founder_annual .or(founder_monthly) .or(sticker_annual) .or(sticker_monthly), (true, BillingInterval::Monthly) => founder_monthly .or(sticker_annual) .or(sticker_monthly), (false, BillingInterval::Annual) => sticker_annual.or(sticker_monthly), (false, BillingInterval::Monthly) => sticker_monthly, } .ok_or_else(|| AppError::BadRequest("Creator tiers are not configured".to_string()))?; // Check not already subscribed if db::creator_tiers::get_active_creator_tier(&state.db, user.id).await?.is_some() { return Ok(Redirect::to("/dashboard?tab=creator").into_response()); } let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let success_url = format!("{}/dashboard?tab=creator&subscribed=true", state.config.host_url); let cancel_url = format!("{}/dashboard?tab=creator", state.config.host_url); let session = stripe.create_creator_tier_checkout_session( price_id, user.id, &tier.to_string(), &success_url, &cancel_url, ).await?; // Mark the user as a founder eagerly on first checkout-session creation // during the open window. We don't gate on actual payment completion // because the webhook handler is the source of truth for the subscription // row; this flag just records "tried to sign up during the window," which // is the correct grain for the snapshot at close-time (the close sweep // only locks users with an active subscription, so abandoned checkouts // don't get locked in regardless). if state.config.creator_founder_window_open && !db_user.is_founder { db::users::mark_user_as_founder(&state.db, user.id).await?; } let checkout_url = session.url .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; Ok(Redirect::to(&checkout_url).into_response()) } /// Form data for subscription checkout (supports optional promo code). #[derive(Debug, Deserialize)] pub(in crate::routes::stripe) struct SubscribeForm { promo_code: Option, } /// POST /stripe/subscribe/{tier_id} - Create a subscription checkout and redirect #[tracing::instrument(skip_all, name = "stripe::subscribe")] pub(in crate::routes::stripe) async fn create_subscription_checkout( State(state): State, AuthUser(user): AuthUser, Path(tier_id): Path, Form(form): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; let tier_uuid: SubscriptionTierId = tier_id.parse() .map_err(|_| AppError::NotFound)?; // Get the tier (must be active and have Stripe IDs) let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tier_uuid) .await? .ok_or(AppError::NotFound)?; if !tier.is_active { return Err(AppError::BadRequest("This subscription tier is not available".to_string())); } let stripe_price_id = tier.stripe_price_id.as_ref() .ok_or_else(|| AppError::BadRequest("Subscription tier is not configured for payments".to_string()))?; // Get the project and creator let tier_project_id = tier.project_id .ok_or_else(|| AppError::BadRequest("This tier is not a project subscription".to_string()))?; let project = db::projects::get_project_by_id(&state.db, tier_project_id) .await? .ok_or(AppError::NotFound)?; let creator = db::users::get_user_by_id(&state.db, project.user_id) .await? .ok_or(AppError::NotFound)?; if creator.is_suspended() || creator.is_deactivated() || creator.is_creator_paused() { return Err(AppError::BadRequest("This creator's account is not active".to_string())); } // Sandbox creators have fake Stripe IDs — reject before calling Stripe API if creator.is_sandbox { return Err(AppError::NotFound); } // Verify creator has Stripe connected let stripe_account_id = creator.stripe_account_id.as_ref() .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?; if !creator.stripe_charges_enabled { return Err(AppError::BadRequest("Creator's payment account is not ready".to_string())); } // A user cannot subscribe to their own project if user.id == project.user_id { return Err(AppError::BadRequest("You cannot subscribe to your own project".to_string())); } // Check if user already has an active subscription to this project if db::subscriptions::has_access(&state.db, user.id, db::subscriptions::SubscriptionScope::Project(tier_project_id)).await? { return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response()); } let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; // Validate optional promo code for free trial let mut trial_days: Option = None; let mut promo_code_id: Option = None; if let Some(code_str) = form.promo_code.as_deref() { let code_str = code_str.trim().to_uppercase(); if !code_str.is_empty() { let pc = db::promo_codes::get_promo_code_by_creator_and_code(&state.db, project.user_id, &code_str) .await? .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?; if pc.code_purpose != CodePurpose::FreeTrial { return Err(AppError::BadRequest("This code is not a free trial code".to_string())); } // Check start date if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() { return Err(AppError::BadRequest("This code is not yet active".to_string())); } // Check expiry if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { return Err(AppError::BadRequest("This code has expired".to_string())); } // Check max uses if let Some(max) = pc.max_uses && pc.use_count >= max { return Err(AppError::BadRequest("This code has reached its usage limit".to_string())); } // Check tier scope if let Some(scoped_tier) = pc.tier_id && scoped_tier != tier_uuid { return Err(AppError::BadRequest("This code is not valid for this tier".to_string())); } // Check project scope if let Some(scoped_project) = pc.project_id && tier_project_id != scoped_project { return Err(AppError::BadRequest("This code is not valid for this project".to_string())); } trial_days = pc.trial_days; promo_code_id = Some(pc.id); } } // Reserve promo code use_count at checkout time to prevent concurrent over-use if let Some(pc_id) = promo_code_id { let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id) .await .context("reserve promo code use at subscription checkout")?; if !reserved { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } } // Build URLs let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url); let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug); // Create the subscription checkout session on the connected account. // If this fails, release the promo code reservation. let session = match stripe.create_subscription_checkout_session( &crate::payments::SubscriptionCheckoutParams { connected_account_id: stripe_account_id, stripe_price_id, subscriber_id: user.id, project_id: tier_project_id, tier_id: tier_uuid, success_url: &success_url, cancel_url: &cancel_url, trial_days, promo_code_id, enable_stripe_tax: creator.stripe_tax_enabled, }, ).await { Ok(s) => s, Err(e) => { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } return Err(e); } }; // Create a pending transaction so that `cleanup_stale_pending_transactions` // can release the promo code reservation if the buyer abandons checkout. // This row is deleted (not completed) when the subscription webhook fires. if let Some(pc_id) = promo_code_id && let Err(e) = db::transactions::create_subscription_pending_transaction( &state.db, user.id, project.user_id, tier_project_id, &session.id, pc_id, ).await { // If we can't create the pending row, release the reservation and fail. db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); return Err(e).context("create subscription pending transaction for promo code"); } // Redirect to Stripe Checkout let checkout_url = session.url .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; Ok(Redirect::to(&checkout_url).into_response()) }