//! Unified promo code management API for creators and public claim endpoint. use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{IntoResponse, Response}, Form, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, CodePurpose, DiscountType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId}, error::{AppError, Result}, helpers::{self, hx_toast, is_htmx_request}, templates::PromoCodesListTemplate, types::PromoCodeRow, types::ListResponse, AppState, }; use super::verify_item_ownership; /// JSON response representing a promo code. #[derive(Debug, Serialize)] struct PromoCodeResponse { id: PromoCodeId, code: String, code_purpose: CodePurpose, discount_type: Option, discount_value: Option, trial_days: Option, max_uses: Option, use_count: i32, } /// JSON response for a free_access code claim. #[derive(Debug, Serialize)] struct ClaimPromoCodeResponse { success: bool, already_owned: bool, item_id: ItemId, } // ============================================================================= // Creator management (auth required) // ============================================================================= /// Form input for creating a promo code. #[derive(Debug, Deserialize)] pub struct CreatePromoCodeForm { pub code: Option, pub code_purpose: CodePurpose, pub discount_type: Option, pub discount_value: Option, pub trial_days: Option, pub max_uses: Option, /// Optional expiry date (HTML date input: YYYY-MM-DD). pub expires_at: Option, /// Optional start date (HTML date input: YYYY-MM-DD). pub starts_at: Option, pub item_id: Option, pub project_id: Option, pub tier_id: Option, } /// Create a new promo code (creator dashboard). #[tracing::instrument(skip_all, name = "promo_codes::create_promo_code")] pub(super) async fn create_promo_code( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; // Generate or validate code let code = match req.code_purpose { CodePurpose::FreeAccess => { // Auto-generate word-based code for free_access (keep lowercase) if let Some(ref c) = req.code { let c = c.trim().to_string(); if c.is_empty() { helpers::generate_key_code().into_inner() } else if c.len() > 100 { return Err(AppError::BadRequest("Code must be at most 100 characters".to_string())); } else { c } } else { helpers::generate_key_code().into_inner() } } _ => { let code = req.code.as_deref().unwrap_or("").trim().to_uppercase(); if code.is_empty() || code.len() > 50 { return Err(AppError::BadRequest("Code must be 1-50 characters".to_string())); } code } }; // Validate purpose-specific fields match req.code_purpose { CodePurpose::Discount => { let dt = req.discount_type .ok_or_else(|| AppError::BadRequest("Discount type is required".to_string()))?; let dv = req.discount_value .ok_or_else(|| AppError::BadRequest("Discount value is required".to_string()))?; match dt { DiscountType::Percentage => { if !(1..=100).contains(&dv) { return Err(AppError::BadRequest("Percentage must be 1-100".to_string())); } } DiscountType::Fixed => { if dv < 1 { return Err(AppError::BadRequest("Fixed discount must be at least 1 cent".to_string())); } if dv > crate::constants::MAX_PRICE_CENTS { return Err(AppError::BadRequest(format!("Fixed discount must be at most ${:.2}", crate::constants::MAX_PRICE_CENTS as f64 / 100.0))); } } } } CodePurpose::FreeTrial => { let days = req.trial_days .ok_or_else(|| AppError::BadRequest("Trial days is required".to_string()))?; if days < 1 { return Err(AppError::BadRequest("Trial days must be at least 1".to_string())); } if days > 365 { return Err(AppError::BadRequest("Trial days must be at most 365".to_string())); } } CodePurpose::FreeAccess => { // No extra validation needed } } if let Some(max) = req.max_uses && max < 1 { return Err(AppError::BadRequest("Max uses must be at least 1".to_string())); } // Parse optional item_id let item_id = if let Some(ref id_str) = req.item_id { let id_str = id_str.trim(); if id_str.is_empty() { None } else { let item_id: ItemId = id_str.parse() .map_err(|_| AppError::BadRequest("Invalid item ID".to_string()))?; verify_item_ownership(&state, item_id, user.id).await?; Some(item_id) } } else { None }; // Parse optional project_id let project_id = if let Some(ref id_str) = req.project_id { let id_str = id_str.trim(); if id_str.is_empty() { None } else { let pid: ProjectId = id_str.parse() .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?; let project = db::projects::get_project_by_id(&state.db, pid) .await? .ok_or(AppError::NotFound)?; if project.user_id != user.id { return Err(AppError::Forbidden); } Some(pid) } } else { None }; // Parse optional tier_id (verify ownership via tier → project → user) let tier_id = if let Some(ref id_str) = req.tier_id { let id_str = id_str.trim(); if id_str.is_empty() { None } else { let tid: SubscriptionTierId = id_str.parse() .map_err(|_| AppError::BadRequest("Invalid tier ID".to_string()))?; let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tid) .await? .ok_or(AppError::NotFound)?; let tier_project_id = tier.project_id .ok_or(AppError::BadRequest("Tier has no project".to_string()))?; let tier_project = db::projects::get_project_by_id(&state.db, tier_project_id) .await? .ok_or(AppError::NotFound)?; if tier_project.user_id != user.id { return Err(AppError::Forbidden); } Some(tid) } } else { None }; // Parse optional expiry date (YYYY-MM-DD from HTML date input) let expires_at = if let Some(ref date_str) = req.expires_at { let date_str = date_str.trim(); if date_str.is_empty() { None } else { let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") .map_err(|_| AppError::BadRequest("Invalid expiry date".to_string()))?; Some(date.and_hms_opt(23, 59, 59) .expect("23:59:59 is a valid time") .and_utc()) } } else { None }; // Parse optional start date (YYYY-MM-DD from HTML date input) let starts_at = if let Some(ref date_str) = req.starts_at { let date_str = date_str.trim(); if date_str.is_empty() { None } else { let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") .map_err(|_| AppError::BadRequest("Invalid start date".to_string()))?; Some(date.and_hms_opt(0, 0, 0) .expect("00:00:00 is a valid time") .and_utc()) } } else { None }; // Validate starts_at < expires_at if both present if let (Some(start), Some(end)) = (starts_at, expires_at) && start >= end { return Err(AppError::BadRequest("Start date must be before expiry date".to_string())); } // Reject already-expired codes if let Some(exp) = expires_at && exp < chrono::Utc::now() { return Err(AppError::BadRequest("Expiry date must be in the future".to_string())); } let promo_code = match db::promo_codes::create_promo_code( &state.db, user.id, &code, req.code_purpose, req.discount_type, req.discount_value, 0, // min_price_cents defaults to 0 req.trial_days, req.max_uses, expires_at, starts_at, item_id, project_id, tier_id, ) .await { Ok(pc) => pc, Err(AppError::Database(sqlx::Error::Database(ref db_err))) if db_err.code().as_deref() == Some("23505") => { return Err(AppError::BadRequest("A promo code with that name already exists".to_string())); } Err(e) => return Err(e), }; if let Some(pid) = project_id { db::projects::bump_cache_generation(&state.db, pid).await?; } if is_htmx_request(&headers) { // Return project-scoped codes if created from project context, otherwise creator-global let codes = if let Some(pid) = project_id { db::promo_codes::get_promo_codes_by_project(&state.db, pid).await? } else { db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await? }; return Ok(( [("HX-Trigger", hx_toast("Promo code created", "success"))], PromoCodesListTemplate { promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), }, ) .into_response()); } Ok(Json(PromoCodeResponse { id: promo_code.id, code: promo_code.code, code_purpose: promo_code.code_purpose, discount_type: promo_code.discount_type, discount_value: promo_code.discount_value, trial_days: promo_code.trial_days, max_uses: promo_code.max_uses, use_count: promo_code.use_count, }) .into_response()) } /// List all promo codes for the authenticated creator. #[tracing::instrument(skip_all, name = "promo_codes::list_promo_codes")] pub(super) async fn list_promo_codes( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?; if is_htmx_request(&headers) { return Ok(PromoCodesListTemplate { promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), } .into_response()); } let data: Vec = codes.into_iter().map(|c| PromoCodeResponse { id: c.id, code: c.code, code_purpose: c.code_purpose, discount_type: c.discount_type, discount_value: c.discount_value, trial_days: c.trial_days, max_uses: c.max_uses, use_count: c.use_count, }).collect(); Ok(Json(ListResponse { data }).into_response()) } /// List redemptions of a promo code (creator dashboard). /// /// Authenticated; the caller must own the code. Returns at most 500 rows of /// `(redeemed_at, buyer, item, amount)`; guest checkouts surface as the /// guest's email with `username = None`. Codes that exceed 500 redemptions /// should be exported via the CSV flow (separate endpoint, not built yet — /// log a TODO if you hit it). #[tracing::instrument(skip_all, name = "promo_codes::list_redemptions")] pub(super) async fn list_redemptions( State(state): State, AuthUser(user): AuthUser, Path(code_id): Path, ) -> Result { let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) .await? .ok_or(AppError::NotFound)?; if promo_code.creator_id != user.id { return Err(AppError::Forbidden); } let rows = db::promo_codes::list_redemptions(&state.db, code_id).await?; Ok(Json(serde_json::json!({ "redemptions": rows })).into_response()) } /// Delete a promo code. #[tracing::instrument(skip_all, name = "promo_codes::delete_promo_code")] pub(super) async fn delete_promo_code( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(code_id): Path, ) -> Result { user.check_not_suspended()?; let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) .await? .ok_or(AppError::NotFound)?; if promo_code.creator_id != user.id { return Err(AppError::Forbidden); } let deleted_project_id = promo_code.project_id; db::promo_codes::delete_promo_code(&state.db, code_id).await?; if let Some(pid) = deleted_project_id { db::projects::bump_cache_generation(&state.db, pid).await?; } if is_htmx_request(&headers) { let codes = if let Some(pid) = deleted_project_id { db::promo_codes::get_promo_codes_by_project(&state.db, pid).await? } else { db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await? }; return Ok(( [("HX-Trigger", hx_toast("Promo code deleted", "success"))], PromoCodesListTemplate { promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), }, ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Form input for updating a promo code. #[derive(Debug, Deserialize)] pub struct UpdatePromoCodeForm { pub max_uses: Option, pub expires_at: Option, pub starts_at: Option, } /// Update an existing promo code (expires_at, starts_at, max_uses only). #[tracing::instrument(skip_all, name = "promo_codes::update_promo_code")] pub(super) async fn update_promo_code( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(code_id): Path, Form(req): Form, ) -> Result { user.check_not_suspended()?; let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id) .await? .ok_or(AppError::NotFound)?; if promo_code.creator_id != user.id { return Err(AppError::Forbidden); } // Parse optional fields — empty string means clear, absent means no change let parse_date = |s: &str| -> Result>> { let s = s.trim(); if s.is_empty() { return Ok(None); } let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?; Ok(Some( date.and_hms_opt(23, 59, 59) .expect("23:59:59 is a valid time") .and_utc(), )) }; let expires_at = req.expires_at.as_deref().map(parse_date).transpose()?; let starts_at = req.starts_at.as_deref().map(|s| { let s = s.trim(); if s.is_empty() { return Ok(None); } let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?; Ok::<_, AppError>(Some( date.and_hms_opt(0, 0, 0) .expect("00:00:00 is a valid time") .and_utc(), )) }).transpose()?; let max_uses = req.max_uses.as_deref().map(|s| { let s = s.trim(); if s.is_empty() { return Ok(None); } let n: i32 = s.parse() .map_err(|_| AppError::BadRequest("Invalid max uses".to_string()))?; if n < 1 { return Err(AppError::BadRequest("Max uses must be at least 1".to_string())); } if n < promo_code.use_count { return Err(AppError::BadRequest(format!( "max_uses cannot be less than current use_count ({})", promo_code.use_count ))); } Ok::<_, AppError>(Some(n)) }).transpose()?; db::promo_codes::update_promo_code(&state.db, code_id, expires_at, starts_at, max_uses).await?; if let Some(pid) = promo_code.project_id { db::projects::bump_cache_generation(&state.db, pid).await?; } if is_htmx_request(&headers) { let codes = if let Some(pid) = promo_code.project_id { db::promo_codes::get_promo_codes_by_project(&state.db, pid).await? } else { db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await? }; return Ok(( [("HX-Trigger", hx_toast("Promo code updated", "success"))], PromoCodesListTemplate { promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), }, ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Delete all expired promo codes for this creator. #[tracing::instrument(skip_all, name = "promo_codes::delete_expired")] pub(super) async fn delete_expired_promo_codes( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> Result { user.check_not_suspended()?; let count = db::promo_codes::delete_expired_by_creator(&state.db, user.id).await?; if is_htmx_request(&headers) { let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?; return Ok(( [("HX-Trigger", hx_toast(&format!("{count} expired code(s) deleted"), "success"))], PromoCodesListTemplate { promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(), }, ) .into_response()); } Ok(Json(serde_json::json!({ "deleted": count })).into_response()) } // ============================================================================= // Public claim (auth required, rate-limited) // ============================================================================= /// Form/JSON input for claiming a free_access promo code. #[derive(Debug, Deserialize)] pub struct ClaimPromoCodeForm { pub code: db::KeyCode, } /// Claim a free_access promo code: validates the code and grants free access to the item. #[tracing::instrument(skip_all, name = "api::claim_promo_code")] pub(super) async fn claim_promo_code( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Form(req): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; let is_htmx = is_htmx_request(&headers); // Look up the code let promo_code = db::promo_codes::get_promo_code_by_code(&state.db, &req.code) .await? .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?; // Only free_access codes can be claimed this way if promo_code.code_purpose != CodePurpose::FreeAccess { return Err(AppError::BadRequest("Invalid promo code".to_string())); } // Must have an item scope let item_id = promo_code.item_id .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?; // Check start date if let Some(starts_at) = promo_code.starts_at && starts_at > chrono::Utc::now() { return Err(AppError::BadRequest("This promo code is not yet active".to_string())); } // Check expiration. Use `<=` so an exact `expires_at == NOW()` clock tick // is treated as expired here — matches the SQL `expires_at > NOW()` guard // in `try_increment_use_count`. Without the alignment, the route would // accept a code right at the boundary, then the atomic SQL would reject // it (rows_affected = 0) and the user gets the wrong error. if let Some(expires_at) = promo_code.expires_at && expires_at <= chrono::Utc::now() { return Err(AppError::BadRequest("This promo code has expired".to_string())); } // Check usage limit if let Some(max_uses) = promo_code.max_uses && promo_code.use_count >= max_uses { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } // Get the item and its seller info for the transaction record let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public { return Err(AppError::NotFound); } let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; let seller = db::users::get_user_by_id(&state.db, project.user_id) .await? .ok_or(AppError::NotFound)?; // Build license key params if the item has keys enabled let key_code = if item.enable_license_keys { Some(helpers::generate_key_code()) } else { None }; let lk_params = key_code.as_ref().map(|kc| db::transactions::LicenseKeyParams { key_code: kc, max_activations: item.default_max_activations, }); // Wrap promo code increment, claim, and license key in a single transaction let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code( &state.db, promo_code.id, &db::transactions::ClaimParams { buyer_id: user.id, item_id, seller_id: project.user_id, item_title: &item.title, seller_username: &seller.username, share_contact: false, parent_transaction_id: None, }, lk_params.as_ref(), ) .await?; if !code_accepted { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } if is_htmx { return Ok(( [("HX-Trigger", hx_toast("Item added to your library", "success"))], Json(ClaimPromoCodeResponse { success: true, already_owned: !claimed, item_id, }), ) .into_response()); } Ok(Json(ClaimPromoCodeResponse { success: true, already_owned: !claimed, item_id, }) .into_response()) }