//! Subscription checkout handlers: Fan+, creator tiers, and project subscriptions. use axum::{ Form, extract::{Path, State}, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use crate::{ Billing, auth::AuthUser, config::Config, db::{self, CodePurpose, PromoCodeId, SubscriptionTierId}, error::{AppError, Result, ResultExt}, }; use sqlx::PgPool; /// 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(db): State, State(payments): State, State(config): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; // Check Fan+ price is configured let price_id = config .creator_pricing .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(&db, user.id).await? { return Ok(Redirect::to("/fan-plus").into_response()); } let stripe = payments .payments .as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let success_url = format!("{}/fan-plus?subscribed=true", config.host_url); let cancel_url = format!("{}/fan-plus", 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(db): State, State(payments): State, headers: axum::http::HeaderMap, AuthUser(user): AuthUser, ) -> Result { check_sec_fetch_site(&headers)?; let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id) .await? .ok_or_else(|| AppError::BadRequest("No active Fan+ subscription".to_string()))?; let stripe = payments .payments .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(&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(db): State, State(payments): State, headers: axum::http::HeaderMap, AuthUser(user): AuthUser, ) -> Result { check_sec_fetch_site(&headers)?; let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id) .await? .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?; let stripe = payments .payments .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(&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(db): State, State(payments): State, State(config): State, AuthUser(user): AuthUser, ) -> Result { let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id) .await? .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?; let portal = payments.payment_caps.require_hosted_portal()?; let return_url = format!("{}/dashboard?tab=account", config.host_url); let url = portal .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, /// Optional comp code: a platform-wide free-trial code that grants N free /// months before the subscription rolls to the chosen (founder) price. #[serde(default)] promo_code: 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(db): State, State(payments): State, State(config): 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 only ever degrade the founder→sticker axis, NEVER // the cadence the buyer chose: // // annual request → founder_annual → sticker_annual // monthly request → founder_monthly → sticker_monthly // // Crossing cadence would silently charge the wrong amount and interval, a // monthly buyer routed to sticker_annual pays the full year up front at a // ~21× larger first charge (Run 21 payments). If the requested cadence // isn't configured for a tier we error clearly rather than mischarge. let db_user = db::users::get_user_by_id(&db, user.id) .await? .ok_or(AppError::NotFound)?; let founder_eligible = config.creator_pricing.founder_window_open || db_user.is_founder_locked(); let interval = BillingInterval::from_form(form.interval.as_deref()); let founder_annual = config.creator_pricing.tier_founder_annual_prices.get(&tier); let founder_monthly = config.creator_pricing.tier_founder_prices.get(&tier); let sticker_annual = config.creator_pricing.tier_annual_prices.get(&tier); let sticker_monthly = config.creator_pricing.tier_prices.get(&tier); let price_id = match (founder_eligible, interval) { (true, BillingInterval::Annual) => founder_annual.or(sticker_annual), (true, BillingInterval::Monthly) => founder_monthly.or(sticker_monthly), (false, BillingInterval::Annual) => sticker_annual, (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(&db, user.id) .await? .is_some() { return Ok(Redirect::to("/dashboard?tab=creator").into_response()); } let stripe = payments .payments .as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let success_url = format!("{}/dashboard?tab=creator&subscribed=true", config.host_url); let cancel_url = format!("{}/dashboard?tab=creator", config.host_url); // Validate an optional comp code (platform-wide free-trial). Unlike the // project-subscription path there's no creator/project scope to check, // these are operator-minted codes redeemable at creator-tier checkout. 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_platform_trial_code_by_code(&db, &code_str) .await? .ok_or_else(|| AppError::BadRequest("Invalid comp code".to_string()))?; if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() { return Err(AppError::BadRequest( "This code is not yet active".to_string(), )); } if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { return Err(AppError::BadRequest("This code has expired".to_string())); } if let Some(max) = pc.max_uses && pc.use_count >= max { return Err(AppError::BadRequest( "This code has reached its usage limit".to_string(), )); } trial_days = pc.trial_days; promo_code_id = Some(pc.id); } } // Reserve the code: per-individual first (once per person), then the global // usage cap. Both are rolled back if the Stripe call below fails. if let Some(pc_id) = promo_code_id { // Once-per-individual: a repeat redemption by the same user is rejected, // so a reusable code shared by a creator grants each person one trial. let first_time = db::promo_codes::try_record_redemption(&db, pc_id, user.id) .await .context("record comp code redemption at creator-tier checkout")?; if !first_time { return Err(AppError::BadRequest( "You have already used this code.".to_string(), )); } // Global usage cap (max_uses). The WHERE clause re-checks the limit. let reserved = db::promo_codes::try_increment_use_count(&db, pc_id) .await .context("reserve comp code use at creator-tier checkout")?; if !reserved { db::promo_codes::remove_redemption(&db, pc_id, user.id) .await .ok(); return Err(AppError::BadRequest( "This code has reached its usage limit".to_string(), )); } } let session = match stripe .create_creator_tier_checkout_session( price_id, user.id, &tier.to_string(), &success_url, &cancel_url, trial_days, ) .await { Ok(s) => s, Err(e) => { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count(&db, pc_id).await.ok(); db::promo_codes::remove_redemption(&db, pc_id, user.id) .await .ok(); } return Err(e); } }; // 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 config.creator_pricing.founder_window_open && !db_user.is_founder { db::users::mark_user_as_founder(&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(db): State, State(payments): State, State(config): 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(&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(&db, tier_project_id) .await? .ok_or(AppError::NotFound)?; let creator = db::users::get_user_by_id(&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_deref() .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( &db, user.id, db::subscriptions::SubscriptionScope::Project(tier_project_id), ) .await? { return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response()); } let stripe = payments .payments .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( &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(&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}}", config.host_url ); let cancel_url = format!("{}/p/{}", 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, currency: creator.settlement_currency, conversion: user.conversion_preference, }) .await { Ok(s) => s, Err(e) => { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&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( &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(&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()) } #[cfg(test)] mod tests { //! The two pure decisions on the Fan+ subscription-change path: which //! cross-origin requests are allowed to reach a billing mutation, and how a //! billing interval is read off a form. Both are one-line functions whose //! wrong answer costs money, and neither was covered. use super::*; fn headers_with(site: &str) -> axum::http::HeaderMap { let mut h = axum::http::HeaderMap::new(); h.insert("sec-fetch-site", site.parse().expect("valid header value")); h } // --- Sec-Fetch-Site --- #[test] fn a_click_from_our_own_dashboard_is_allowed() { assert!(check_sec_fetch_site(&headers_with("same-origin")).is_ok()); } #[test] fn a_browser_that_sends_no_header_is_allowed() { // Older browsers omit it entirely; refusing them would break real users. assert!(check_sec_fetch_site(&axum::http::HeaderMap::new()).is_ok()); } #[test] fn every_other_origin_is_forbidden() { // `same-site` is deliberately refused too: a subdomain is not us. for site in ["cross-site", "same-site", "none"] { assert!( matches!( check_sec_fetch_site(&headers_with(site)), Err(AppError::Forbidden) ), "{site} must not be able to change a subscription" ); } } #[test] fn an_unparseable_header_does_not_open_the_gate() { // A non-UTF-8 value falls into the `None` arm and is allowed, matching // the missing-header case. Pinned so the fallback is a decision rather // than an accident. let mut h = axum::http::HeaderMap::new(); h.insert( "sec-fetch-site", axum::http::HeaderValue::from_bytes(&[0xff, 0xfe]).expect("bytes"), ); assert!(check_sec_fetch_site(&h).is_ok()); } // --- billing interval --- #[test] fn the_interval_defaults_to_monthly() { // The cheaper commitment is the safe default: defaulting to annual // would charge a year for a form that forgot the field. assert_eq!(BillingInterval::from_form(None), BillingInterval::Monthly); assert_eq!( BillingInterval::from_form(Some("")), BillingInterval::Monthly ); assert_eq!( BillingInterval::from_form(Some("nonsense")), BillingInterval::Monthly ); } #[test] fn every_spelling_of_annual_is_accepted() { for s in ["annual", "yearly", "year"] { assert_eq!( BillingInterval::from_form(Some(s)), BillingInterval::Annual, "{s} should select the annual price" ); } } #[test] fn interval_matching_is_case_sensitive() { // Documents the current contract: "Annual" falls through to Monthly. // If a form ever sends a capitalised value it will silently downgrade. assert_eq!( BillingInterval::from_form(Some("Annual")), BillingInterval::Monthly ); } }