//! Admin comp-codes dashboard: mint creator-tier comp codes and monitor status. //! //! A comp code is a platform-wide free-trial promo code redeemable at //! creator-tier checkout. Codes are named after their recipient (e.g. //! `ALPHA-JAMIE`); for a one-use code the status column shows whether that //! recipient has redeemed. See `meta/creator_invite_checklist.md`. use axum::{ extract::State, response::{IntoResponse, Response}, Form, }; use serde::Deserialize; use crate::{ auth::AdminUser, db, error::{AppError, Result}, helpers::get_csrf_token, templates::*, types::*, AppState, }; /// Render the comp-codes dashboard: mint form plus a status list. #[tracing::instrument(skip_all, name = "admin::admin_comp_codes")] pub(super) async fn admin_comp_codes( State(state): State, session: tower_sessions::Session, AdminUser(user): AdminUser, ) -> Result { let csrf_token = get_csrf_token(&session).await; let comp_codes = load_comp_code_rows(&state).await?; Ok(AdminCompCodesTemplate { csrf_token, session_user: Some(user), admin_active_page: "comp-codes", comp_codes, }) } /// Form for minting a creator-tier comp code. #[derive(Debug, Deserialize)] pub(super) struct CompCodeForm { code: String, trial_days: i32, /// Cap on redemptions (omit/blank for unlimited). #[serde(default, deserialize_with = "empty_string_as_none")] max_uses: Option, /// Days from now until the code expires (omit/blank for never). #[serde(default, deserialize_with = "empty_string_as_none")] expires_in_days: Option, } /// Treat an empty form field as `None` (HTML forms post "" for blank numbers). fn empty_string_as_none<'de, D, T>(de: D) -> std::result::Result, D::Error> where D: serde::Deserializer<'de>, T: std::str::FromStr, T::Err: std::fmt::Display, { let opt = Option::::deserialize(de)?; match opt.as_deref().map(str::trim) { None | Some("") => Ok(None), Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), } } /// Mint a platform-wide free-trial code redeemable at creator-tier checkout. /// /// The redeemer gets `trial_days` free with no card collected up front, after /// which the subscription rolls to the price chosen at checkout (the founder /// price while the founder window is open). Owned by the minting admin for /// audit, redeemable by any holder — control distribution via `max_uses` / /// `expires_in_days`. On success the comp-codes table is re-rendered so the /// new code appears immediately. #[tracing::instrument(skip_all, name = "admin::admin_create_comp_code")] pub(super) async fn admin_create_comp_code( State(state): State, AdminUser(admin): AdminUser, Form(form): Form, ) -> Result { let code = form.code.trim().to_uppercase(); if code.is_empty() { return Err(AppError::BadRequest("Code is required".to_string())); } if form.trial_days <= 0 { return Err(AppError::BadRequest("Trial days must be positive".to_string())); } let expires_at = form .expires_in_days .map(|d| chrono::Utc::now() + chrono::Duration::days(d)); db::promo_codes::create_platform_promo_code( &state.db, admin.id, &code, db::CodePurpose::FreeTrial, None, // discount_type None, // discount_value 0, // min_price_cents (unused for trials) Some(form.trial_days), form.max_uses, expires_at, ) .await?; tracing::info!(code = %code, trial_days = form.trial_days, "minted creator-tier comp code"); let comp_codes = load_comp_code_rows(&state).await?; Ok(AdminCompCodesEntriesTemplate { comp_codes }.into_response()) } /// Load and format the comp-code rows for the dashboard. async fn load_comp_code_rows(state: &AppState) -> Result> { let codes = db::promo_codes::get_platform_trial_codes(&state.db).await?; Ok(codes.iter().map(AdminCompCodeRow::from_db).collect()) }