max / makenotwork
9 files changed,
+356 insertions,
-9 deletions
| @@ -453,6 +453,29 @@ pub async fn get_platform_promo_code_by_user_and_code( | |||
| 453 | 453 | Ok(promo_code) | |
| 454 | 454 | } | |
| 455 | 455 | ||
| 456 | + | /// Look up a platform-wide free-trial code by code string (case-insensitive). | |
| 457 | + | /// | |
| 458 | + | /// Used to comp creator-tier subscriptions: anyone holding the code can redeem | |
| 459 | + | /// it at creator-tier checkout for `trial_days` free, after which the | |
| 460 | + | /// subscription rolls to the price chosen at checkout (founder price during the | |
| 461 | + | /// founder window). Scoped to `free_trial` + `is_platform_wide` so it can't | |
| 462 | + | /// collide with creator-scoped discount codes or per-user Fan+ credits. | |
| 463 | + | #[tracing::instrument(skip_all)] | |
| 464 | + | pub async fn get_platform_trial_code_by_code( | |
| 465 | + | pool: &PgPool, | |
| 466 | + | code: &str, | |
| 467 | + | ) -> Result<Option<DbPromoCode>> { | |
| 468 | + | let promo_code = sqlx::query_as::<_, DbPromoCode>( | |
| 469 | + | "SELECT * FROM promo_codes \ | |
| 470 | + | WHERE upper(code) = upper($1) AND code_purpose = 'free_trial' AND is_platform_wide = true", | |
| 471 | + | ) | |
| 472 | + | .bind(code) | |
| 473 | + | .fetch_optional(pool) | |
| 474 | + | .await?; | |
| 475 | + | ||
| 476 | + | Ok(promo_code) | |
| 477 | + | } | |
| 478 | + | ||
| 456 | 479 | /// Apply a discount to a price, returning the discounted price in cents (minimum 0). | |
| 457 | 480 | /// Negative discount values are clamped to 0 to prevent price increases. | |
| 458 | 481 | #[tracing::instrument(skip_all)] |
| @@ -10,7 +10,7 @@ use stripe_checkout::checkout_session::{ | |||
| 10 | 10 | CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, | |
| 11 | 11 | CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataRecurring, | |
| 12 | 12 | CreateCheckoutSessionLineItemsPriceDataRecurringInterval, | |
| 13 | - | CreateCheckoutSessionSubscriptionData, ProductData, | |
| 13 | + | CreateCheckoutSessionPaymentMethodCollection, CreateCheckoutSessionSubscriptionData, ProductData, | |
| 14 | 14 | }; | |
| 15 | 15 | use stripe_shared::CheckoutSessionMode; | |
| 16 | 16 | use stripe_types::Currency; | |
| @@ -403,19 +403,39 @@ impl StripeClient { | |||
| 403 | 403 | tier: &str, | |
| 404 | 404 | success_url: &str, | |
| 405 | 405 | cancel_url: &str, | |
| 406 | + | trial_days: Option<i32>, | |
| 406 | 407 | ) -> Result<stripe_shared::CheckoutSession> { | |
| 407 | 408 | let mut metadata = HashMap::new(); | |
| 408 | 409 | metadata.insert("checkout_type".to_string(), CheckoutType::CreatorTier.to_string()); | |
| 409 | 410 | metadata.insert("user_id".to_string(), user_id.to_string()); | |
| 410 | 411 | metadata.insert("tier".to_string(), tier.to_string()); | |
| 411 | 412 | ||
| 412 | - | let builder = CreateCheckoutSession::new() | |
| 413 | + | let mut builder = CreateCheckoutSession::new() | |
| 413 | 414 | .mode(CheckoutSessionMode::Subscription) | |
| 414 | 415 | .success_url(success_url.to_string()) | |
| 415 | 416 | .cancel_url(cancel_url.to_string()) | |
| 416 | 417 | .line_items(vec![build_price_line_item(price_id)]) | |
| 417 | 418 | .metadata(metadata); | |
| 418 | 419 | ||
| 420 | + | // A comp code grants a free trial: don't collect a card up front | |
| 421 | + | // (`if_required` skips card collection when no charge is due yet), and | |
| 422 | + | // delay the first charge by `trial_days`. With no payment method on | |
| 423 | + | // file, the subscription simply lapses at trial end unless the creator | |
| 424 | + | // adds one — continuing is an explicit opt-in, never a silent charge. | |
| 425 | + | // The price stays the one chosen by the caller (founder price during | |
| 426 | + | // the founder window), so opting in renews at that rate. | |
| 427 | + | if let Some(days) = trial_days { | |
| 428 | + | let days: u32 = days.try_into().map_err(|_| { | |
| 429 | + | AppError::BadRequest("Invalid trial period".to_string()) | |
| 430 | + | })?; | |
| 431 | + | builder = builder | |
| 432 | + | .payment_method_collection(CreateCheckoutSessionPaymentMethodCollection::IfRequired) | |
| 433 | + | .subscription_data(CreateCheckoutSessionSubscriptionData { | |
| 434 | + | trial_period_days: Some(days), | |
| 435 | + | ..CreateCheckoutSessionSubscriptionData::new() | |
| 436 | + | }); | |
| 437 | + | } | |
| 438 | + | ||
| 419 | 439 | self.send_on_platform(builder, "creator_tier_checkout").await | |
| 420 | 440 | } | |
| 421 | 441 |
| @@ -82,7 +82,7 @@ pub trait PaymentProvider: Send + Sync { | |||
| 82 | 82 | async fn create_subscription_checkout_session(&self, params: &SubscriptionCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 83 | 83 | async fn create_tip_checkout_session(&self, params: &TipCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 84 | 84 | async fn create_fan_plus_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 85 | - | async fn create_creator_tier_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 85 | + | async fn create_creator_tier_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str, trial_days: Option<i32>) -> crate::error::Result<CheckoutResult>; | |
| 86 | 86 | async fn create_synckit_app_sub_checkout_session(&self, params: &SynckitAppSubCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 87 | 87 | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 88 | 88 | ||
| @@ -153,8 +153,8 @@ impl PaymentProvider for StripeClient { | |||
| 153 | 153 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | - | async fn create_creator_tier_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult> { | |
| 157 | - | let session = StripeClient::create_creator_tier_checkout_session(self, price_id, user_id, tier, success_url, cancel_url).await?; | |
| 156 | + | async fn create_creator_tier_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str, trial_days: Option<i32>) -> crate::error::Result<CheckoutResult> { | |
| 157 | + | let session = StripeClient::create_creator_tier_checkout_session(self, price_id, user_id, tier, success_url, cancel_url, trial_days).await?; | |
| 158 | 158 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 159 | 159 | } | |
| 160 | 160 |
| @@ -75,6 +75,7 @@ pub fn admin_routes() -> CsrfRouter<AppState> { | |||
| 75 | 75 | .route("/api/admin/shutdown-notice", post_csrf(admin_shutdown_notice)) | |
| 76 | 76 | // Founder pricing | |
| 77 | 77 | .route("/api/admin/founder-window/close", post_csrf(admin_close_founder_window)) | |
| 78 | + | .route("/api/admin/comp-codes/create", post_csrf(admin_create_comp_code)) | |
| 78 | 79 | // Metrics | |
| 79 | 80 | .route_get("/admin/metrics", get(admin_metrics)) | |
| 80 | 81 | } | |
| @@ -251,6 +252,77 @@ async fn admin_close_founder_window( | |||
| 251 | 252 | ).into_response()) | |
| 252 | 253 | } | |
| 253 | 254 | ||
| 255 | + | // ── Comp codes ── | |
| 256 | + | ||
| 257 | + | /// Form for minting a creator-tier comp code. | |
| 258 | + | #[derive(Debug, Deserialize)] | |
| 259 | + | struct CompCodeForm { | |
| 260 | + | code: String, | |
| 261 | + | trial_days: i32, | |
| 262 | + | /// Cap on redemptions (omit for unlimited). | |
| 263 | + | #[serde(default)] | |
| 264 | + | max_uses: Option<i32>, | |
| 265 | + | /// Days from now until the code expires (omit for never). | |
| 266 | + | #[serde(default)] | |
| 267 | + | expires_in_days: Option<i64>, | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// Mint a platform-wide free-trial code redeemable at creator-tier checkout. | |
| 271 | + | /// | |
| 272 | + | /// Used for operator comps (e.g. alpha-tester 6-month grants): the redeemer | |
| 273 | + | /// gets `trial_days` free with no card collected up front, after which the | |
| 274 | + | /// subscription rolls to the price chosen at checkout — the founder price while | |
| 275 | + | /// the founder window is open. The code is owned by the minting admin for audit | |
| 276 | + | /// but is redeemable by any holder; control distribution via `max_uses` and | |
| 277 | + | /// `expires_in_days`. See `meta/creator_invite_checklist.md`. | |
| 278 | + | #[tracing::instrument(skip_all, name = "admin::admin_create_comp_code")] | |
| 279 | + | async fn admin_create_comp_code( | |
| 280 | + | State(state): State<AppState>, | |
| 281 | + | AdminUser(admin): AdminUser, | |
| 282 | + | Form(form): Form<CompCodeForm>, | |
| 283 | + | ) -> Result<Response> { | |
| 284 | + | let code = form.code.trim().to_uppercase(); | |
| 285 | + | if code.is_empty() { | |
| 286 | + | return Err(AppError::BadRequest("Code is required".to_string())); | |
| 287 | + | } | |
| 288 | + | if form.trial_days <= 0 { | |
| 289 | + | return Err(AppError::BadRequest("trial_days must be positive".to_string())); | |
| 290 | + | } | |
| 291 | + | let expires_at = form | |
| 292 | + | .expires_in_days | |
| 293 | + | .map(|d| chrono::Utc::now() + chrono::Duration::days(d)); | |
| 294 | + | ||
| 295 | + | let pc = db::promo_codes::create_platform_promo_code( | |
| 296 | + | &state.db, | |
| 297 | + | admin.id, | |
| 298 | + | &code, | |
| 299 | + | db::CodePurpose::FreeTrial, | |
| 300 | + | None, // discount_type | |
| 301 | + | None, // discount_value | |
| 302 | + | 0, // min_price_cents (unused for trials) | |
| 303 | + | Some(form.trial_days), | |
| 304 | + | form.max_uses, | |
| 305 | + | expires_at, | |
| 306 | + | ) | |
| 307 | + | .await?; | |
| 308 | + | ||
| 309 | + | tracing::info!(code = %pc.code, trial_days = form.trial_days, "minted creator-tier comp code"); | |
| 310 | + | Ok(( | |
| 311 | + | axum::http::StatusCode::OK, | |
| 312 | + | format!( | |
| 313 | + | "Comp code '{}' created: {} trial day{}{}.", | |
| 314 | + | pc.code, | |
| 315 | + | form.trial_days, | |
| 316 | + | if form.trial_days == 1 { "" } else { "s" }, | |
| 317 | + | match form.max_uses { | |
| 318 | + | Some(m) => format!(", max {m} use(s)"), | |
| 319 | + | None => String::new(), | |
| 320 | + | } | |
| 321 | + | ), | |
| 322 | + | ) | |
| 323 | + | .into_response()) | |
| 324 | + | } | |
| 325 | + | ||
| 254 | 326 | // ── Metrics ── | |
| 255 | 327 | ||
| 256 | 328 | /// Render the admin metrics dashboard with live Prometheus data. |
| @@ -161,6 +161,10 @@ pub(in crate::routes::stripe) struct CreatorTierForm { | |||
| 161 | 161 | /// keep working. | |
| 162 | 162 | #[serde(default)] | |
| 163 | 163 | interval: Option<String>, | |
| 164 | + | /// Optional comp code: a platform-wide free-trial code that grants N free | |
| 165 | + | /// months before the subscription rolls to the chosen (founder) price. | |
| 166 | + | #[serde(default)] | |
| 167 | + | promo_code: Option<String>, | |
| 164 | 168 | } | |
| 165 | 169 | ||
| 166 | 170 | /// Billing cadence requested by the checkout form. We try (founder|sticker) | |
| @@ -241,13 +245,60 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout( | |||
| 241 | 245 | let success_url = format!("{}/dashboard?tab=creator&subscribed=true", state.config.host_url); | |
| 242 | 246 | let cancel_url = format!("{}/dashboard?tab=creator", state.config.host_url); | |
| 243 | 247 | ||
| 244 | - | let session = stripe.create_creator_tier_checkout_session( | |
| 248 | + | // Validate an optional comp code (platform-wide free-trial). Unlike the | |
| 249 | + | // project-subscription path there's no creator/project scope to check — | |
| 250 | + | // these are operator-minted codes redeemable at creator-tier checkout. | |
| 251 | + | let mut trial_days: Option<i32> = None; | |
| 252 | + | let mut promo_code_id: Option<PromoCodeId> = None; | |
| 253 | + | if let Some(code_str) = form.promo_code.as_deref() { | |
| 254 | + | let code_str = code_str.trim().to_uppercase(); | |
| 255 | + | if !code_str.is_empty() { | |
| 256 | + | let pc = db::promo_codes::get_platform_trial_code_by_code(&state.db, &code_str) | |
| 257 | + | .await? | |
| 258 | + | .ok_or_else(|| AppError::BadRequest("Invalid comp code".to_string()))?; | |
| 259 | + | ||
| 260 | + | if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() { | |
| 261 | + | return Err(AppError::BadRequest("This code is not yet active".to_string())); | |
| 262 | + | } | |
| 263 | + | if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { | |
| 264 | + | return Err(AppError::BadRequest("This code has expired".to_string())); | |
| 265 | + | } | |
| 266 | + | if let Some(max) = pc.max_uses && pc.use_count >= max { | |
| 267 | + | return Err(AppError::BadRequest("This code has reached its usage limit".to_string())); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | trial_days = pc.trial_days; | |
| 271 | + | promo_code_id = Some(pc.id); | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Reserve a use atomically (the WHERE clause re-checks the limit, closing | |
| 276 | + | // the read-to-reserve race). Released below if the Stripe call then fails. | |
| 277 | + | if let Some(pc_id) = promo_code_id { | |
| 278 | + | let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id) | |
| 279 | + | .await | |
| 280 | + | .context("reserve comp code use at creator-tier checkout")?; | |
| 281 | + | if !reserved { | |
| 282 | + | return Err(AppError::BadRequest("This code has reached its usage limit".to_string())); | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | let session = match stripe.create_creator_tier_checkout_session( | |
| 245 | 287 | price_id, | |
| 246 | 288 | user.id, | |
| 247 | 289 | &tier.to_string(), | |
| 248 | 290 | &success_url, | |
| 249 | 291 | &cancel_url, | |
| 250 | - | ).await?; | |
| 292 | + | trial_days, | |
| 293 | + | ).await { | |
| 294 | + | Ok(s) => s, | |
| 295 | + | Err(e) => { | |
| 296 | + | if let Some(pc_id) = promo_code_id { | |
| 297 | + | db::promo_codes::release_use_count(&state.db, pc_id).await.ok(); | |
| 298 | + | } | |
| 299 | + | return Err(e); | |
| 300 | + | } | |
| 301 | + | }; | |
| 251 | 302 | ||
| 252 | 303 | // Mark the user as a founder eagerly on first checkout-session creation | |
| 253 | 304 | // during the open window. We don't gate on actual payment completion |
| @@ -81,6 +81,9 @@ pub struct BuildOptions { | |||
| 81 | 81 | pub access_gate: makenotwork::config::AccessGate, | |
| 82 | 82 | /// Delegated-login (SSO) provider config. `None` = local password form. | |
| 83 | 83 | pub sso: Option<makenotwork::config::SsoConfig>, | |
| 84 | + | /// Sticker monthly creator-tier price IDs. `None` = empty (creator-tier | |
| 85 | + | /// checkout is unconfigured and bails). Set to exercise creator-tier flows. | |
| 86 | + | pub creator_tier_prices: Option<std::collections::HashMap<makenotwork::db::CreatorTier, String>>, | |
| 84 | 87 | } | |
| 85 | 88 | ||
| 86 | 89 | /// Full test harness: isolated database, in-process app, cookie-aware client. | |
| @@ -189,6 +192,25 @@ impl TestHarness { | |||
| 189 | 192 | harness | |
| 190 | 193 | } | |
| 191 | 194 | ||
| 195 | + | /// Harness wired for creator-tier checkout: mock Stripe plus a configured | |
| 196 | + | /// sticker price for the Everything tier (the founder window stays closed, | |
| 197 | + | /// so checkout uses the sticker price ID — the mock ignores it anyway). | |
| 198 | + | /// Exposes `mock_stripe` for asserting on the trial passed to Stripe. | |
| 199 | + | #[allow(dead_code)] | |
| 200 | + | pub async fn with_creator_tier_checkout() -> Self { | |
| 201 | + | let mock_stripe = Arc::new(stripe::MockPaymentProvider::new()); | |
| 202 | + | let mut prices = std::collections::HashMap::new(); | |
| 203 | + | prices.insert(makenotwork::db::CreatorTier::Everything, "price_test_everything".to_string()); | |
| 204 | + | let mut harness = Self::build(BuildOptions { | |
| 205 | + | storage: Some(Arc::new(InMemoryStorage::new())), | |
| 206 | + | stripe_client: Some(mock_stripe.clone() as Arc<dyn PaymentProvider>), | |
| 207 | + | creator_tier_prices: Some(prices), | |
| 208 | + | ..Default::default() | |
| 209 | + | }).await; | |
| 210 | + | harness.mock_stripe = Some(mock_stripe); | |
| 211 | + | harness | |
| 212 | + | } | |
| 213 | + | ||
| 192 | 214 | /// Harness with admin user configured. Returns (harness, admin_user_id). | |
| 193 | 215 | #[allow(dead_code)] | |
| 194 | 216 | pub async fn with_admin() -> (Self, UserId) { | |
| @@ -294,7 +316,7 @@ impl TestHarness { | |||
| 294 | 316 | git_ssh_host: None, | |
| 295 | 317 | mt_base_url: None, | |
| 296 | 318 | fan_plus_price_id: None, | |
| 297 | - | creator_tier_prices: std::collections::HashMap::new(), | |
| 319 | + | creator_tier_prices: opts.creator_tier_prices.unwrap_or_default(), | |
| 298 | 320 | creator_tier_annual_prices: std::collections::HashMap::new(), | |
| 299 | 321 | creator_tier_founder_prices: std::collections::HashMap::new(), | |
| 300 | 322 | creator_tier_founder_annual_prices: std::collections::HashMap::new(), |
| @@ -68,6 +68,9 @@ pub struct MockCheckout { | |||
| 68 | 68 | pub struct MockPaymentProvider { | |
| 69 | 69 | checkouts: Mutex<Vec<MockCheckout>>, | |
| 70 | 70 | next_checkout_id: Mutex<u64>, | |
| 71 | + | /// `trial_days` passed to each creator-tier checkout, in call order. Lets | |
| 72 | + | /// the comp-code test assert the trial was actually threaded to Stripe. | |
| 73 | + | creator_tier_trial_days: Mutex<Vec<Option<i32>>>, | |
| 71 | 74 | } | |
| 72 | 75 | ||
| 73 | 76 | #[allow(dead_code)] | |
| @@ -76,6 +79,7 @@ impl MockPaymentProvider { | |||
| 76 | 79 | MockPaymentProvider { | |
| 77 | 80 | checkouts: Mutex::new(Vec::new()), | |
| 78 | 81 | next_checkout_id: Mutex::new(1), | |
| 82 | + | creator_tier_trial_days: Mutex::new(Vec::new()), | |
| 79 | 83 | } | |
| 80 | 84 | } | |
| 81 | 85 | ||
| @@ -84,6 +88,11 @@ impl MockPaymentProvider { | |||
| 84 | 88 | self.checkouts.lock().unwrap().clone() | |
| 85 | 89 | } | |
| 86 | 90 | ||
| 91 | + | /// `trial_days` recorded for each creator-tier checkout, in call order. | |
| 92 | + | pub fn creator_tier_trial_days(&self) -> Vec<Option<i32>> { | |
| 93 | + | self.creator_tier_trial_days.lock().unwrap().clone() | |
| 94 | + | } | |
| 95 | + | ||
| 87 | 96 | fn next_session(&self) -> CheckoutResult { | |
| 88 | 97 | let mut counter = self.next_checkout_id.lock().unwrap(); | |
| 89 | 98 | let id = format!("cs_test_{}", *counter); | |
| @@ -119,7 +128,8 @@ impl PaymentProvider for MockPaymentProvider { | |||
| 119 | 128 | Ok(self.next_session()) | |
| 120 | 129 | } | |
| 121 | 130 | ||
| 122 | - | async fn create_creator_tier_checkout_session(&self, _price_id: &str, _user_id: makenotwork::db::UserId, _tier: &str, _success_url: &str, _cancel_url: &str) -> Result<CheckoutResult> { | |
| 131 | + | async fn create_creator_tier_checkout_session(&self, _price_id: &str, _user_id: makenotwork::db::UserId, _tier: &str, _success_url: &str, _cancel_url: &str, trial_days: Option<i32>) -> Result<CheckoutResult> { | |
| 132 | + | self.creator_tier_trial_days.lock().unwrap().push(trial_days); | |
| 123 | 133 | Ok(self.next_session()) | |
| 124 | 134 | } | |
| 125 | 135 |
| @@ -0,0 +1,148 @@ | |||
| 1 | + | //! Creator-tier comp codes: the admin mint route and the row contract that the | |
| 2 | + | //! creator-tier checkout's `get_platform_trial_code_by_code` lookup depends on | |
| 3 | + | //! (platform-wide + free_trial + a trial length). | |
| 4 | + | ||
| 5 | + | use crate::harness::TestHarness; | |
| 6 | + | ||
| 7 | + | #[tokio::test] | |
| 8 | + | async fn admin_mints_creator_tier_comp_code() { | |
| 9 | + | let (mut h, _admin_id) = TestHarness::with_admin().await; | |
| 10 | + | h.login("admin", "password123").await; | |
| 11 | + | ||
| 12 | + | // Lowercase input to also prove the handler uppercases the stored code. | |
| 13 | + | let resp = h | |
| 14 | + | .client | |
| 15 | + | .post_form( | |
| 16 | + | "/api/admin/comp-codes/create", | |
| 17 | + | "code=alpha6mo&trial_days=180&max_uses=10&expires_in_days=60", | |
| 18 | + | ) | |
| 19 | + | .await; | |
| 20 | + | assert!(resp.status.is_success(), "mint failed: {} {}", resp.status, resp.text); | |
| 21 | + | ||
| 22 | + | // The creator-tier checkout lookup filters on exactly these columns, so | |
| 23 | + | // assert the minted row matches: uppercased code, platform-wide, free_trial, | |
| 24 | + | // and the requested trial length / use cap. | |
| 25 | + | let (purpose, platform, trial_days, max_uses): (String, bool, Option<i32>, Option<i32>) = | |
| 26 | + | sqlx::query_as( | |
| 27 | + | "SELECT code_purpose::text, is_platform_wide, trial_days, max_uses \ | |
| 28 | + | FROM promo_codes WHERE code = $1", | |
| 29 | + | ) | |
| 30 | + | .bind("ALPHA6MO") | |
| 31 | + | .fetch_one(&h.db) | |
| 32 | + | .await | |
| 33 | + | .expect("comp code row should exist"); | |
| 34 | + | ||
| 35 | + | assert_eq!(purpose, "free_trial"); | |
| 36 | + | assert!(platform, "comp code must be platform-wide so it resolves at creator-tier checkout"); | |
| 37 | + | assert_eq!(trial_days, Some(180)); | |
| 38 | + | assert_eq!(max_uses, Some(10)); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[tokio::test] | |
| 42 | + | async fn admin_comp_code_rejects_zero_trial_days() { | |
| 43 | + | let (mut h, _admin_id) = TestHarness::with_admin().await; | |
| 44 | + | h.login("admin", "password123").await; | |
| 45 | + | ||
| 46 | + | let resp = h | |
| 47 | + | .client | |
| 48 | + | .post_form("/api/admin/comp-codes/create", "code=BADCOMP&trial_days=0") | |
| 49 | + | .await; | |
| 50 | + | assert_eq!(resp.status, 400, "zero trial days should be rejected: {}", resp.text); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// End-to-end redemption: a regular user redeems a platform-wide free-trial | |
| 54 | + | /// comp code at creator-tier checkout. Proves the new lookup + reserve wiring | |
| 55 | + | /// runs, the trial length is threaded to Stripe, and the code's use is counted. | |
| 56 | + | #[tokio::test] | |
| 57 | + | async fn comp_code_redeemed_at_creator_tier_checkout() { | |
| 58 | + | let mut h = TestHarness::with_creator_tier_checkout().await; | |
| 59 | + | let user_id = h.signup("comptester", "comptester@test.com", "password123").await; | |
| 60 | + | ||
| 61 | + | // Seed a 180-day platform-wide free-trial code (the shape the admin mint | |
| 62 | + | // route produces); creator_id just records ownership. | |
| 63 | + | sqlx::query( | |
| 64 | + | "INSERT INTO promo_codes \ | |
| 65 | + | (creator_id, code, code_purpose, min_price_cents, trial_days, max_uses, is_platform_wide) \ | |
| 66 | + | VALUES ($1, 'ALPHA6MO', 'free_trial', 0, 180, 5, true)", | |
| 67 | + | ) | |
| 68 | + | .bind(*user_id) | |
| 69 | + | .execute(&h.db) | |
| 70 | + | .await | |
| 71 | + | .expect("seed comp code"); | |
| 72 | + | ||
| 73 | + | // Redeem at creator-tier checkout (lowercase code proves the handler upper-cases). | |
| 74 | + | let resp = h | |
| 75 | + | .client | |
| 76 | + | .post_form("/stripe/creator-tier", "tier=everything&promo_code=alpha6mo") | |
| 77 | + | .await; | |
| 78 | + | assert!( | |
| 79 | + | resp.status.is_redirection() || resp.status.is_success(), | |
| 80 | + | "comp redemption should reach Stripe checkout, got: {} {}", | |
| 81 | + | resp.status, resp.text | |
| 82 | + | ); | |
| 83 | + | ||
| 84 | + | // The trial length was threaded through to the Stripe call... | |
| 85 | + | let trials = h.mock_stripe.as_ref().unwrap().creator_tier_trial_days(); | |
| 86 | + | assert_eq!(trials, vec![Some(180)], "the 180-day trial should reach Stripe"); | |
| 87 | + | ||
| 88 | + | // ...and the code's use was reserved exactly once. | |
| 89 | + | let use_count: i32 = | |
| 90 | + | sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'ALPHA6MO'") | |
| 91 | + | .fetch_one(&h.db) | |
| 92 | + | .await | |
| 93 | + | .unwrap(); | |
| 94 | + | assert_eq!(use_count, 1, "redemption should reserve one use"); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// An expired comp code is rejected: no Stripe session, no use burned. | |
| 98 | + | #[tokio::test] | |
| 99 | + | async fn expired_comp_code_rejected_at_creator_tier_checkout() { | |
| 100 | + | let mut h = TestHarness::with_creator_tier_checkout().await; | |
| 101 | + | let user_id = h.signup("exptester", "exptester@test.com", "password123").await; | |
| 102 | + | ||
| 103 | + | sqlx::query( | |
| 104 | + | "INSERT INTO promo_codes \ | |
| 105 | + | (creator_id, code, code_purpose, min_price_cents, trial_days, is_platform_wide, expires_at) \ | |
| 106 | + | VALUES ($1, 'EXPIRED6MO', 'free_trial', 0, 180, true, NOW() - INTERVAL '1 day')", | |
| 107 | + | ) | |
| 108 | + | .bind(*user_id) | |
| 109 | + | .execute(&h.db) | |
| 110 | + | .await | |
| 111 | + | .expect("seed expired comp code"); | |
| 112 | + | ||
| 113 | + | let resp = h | |
| 114 | + | .client | |
| 115 | + | .post_form("/stripe/creator-tier", "tier=everything&promo_code=expired6mo") | |
| 116 | + | .await; | |
| 117 | + | assert_eq!(resp.status, 400, "expired code should be rejected: {}", resp.text); | |
| 118 | + | ||
| 119 | + | assert!( | |
| 120 | + | h.mock_stripe.as_ref().unwrap().checkouts().is_empty(), | |
| 121 | + | "no Stripe session should be created for an expired code" | |
| 122 | + | ); | |
| 123 | + | let use_count: i32 = | |
| 124 | + | sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'EXPIRED6MO'") | |
| 125 | + | .fetch_one(&h.db) | |
| 126 | + | .await | |
| 127 | + | .unwrap(); | |
| 128 | + | assert_eq!(use_count, 0, "a rejected code must not burn a use"); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | #[tokio::test] | |
| 132 | + | async fn comp_code_mint_requires_admin() { | |
| 133 | + | let mut h = TestHarness::new().await; | |
| 134 | + | h.signup("notadmin", "notadmin@test.com", "password123").await; | |
| 135 | + | ||
| 136 | + | let resp = h | |
| 137 | + | .client | |
| 138 | + | .post_form( | |
| 139 | + | "/api/admin/comp-codes/create", | |
| 140 | + | "code=SNEAKY&trial_days=180", | |
| 141 | + | ) | |
| 142 | + | .await; | |
| 143 | + | assert!( | |
| 144 | + | !resp.status.is_success(), | |
| 145 | + | "a non-admin must not be able to mint comp codes (got {})", | |
| 146 | + | resp.status | |
| 147 | + | ); | |
| 148 | + | } |
| @@ -19,6 +19,7 @@ mod synckit_per_key_storage; | |||
| 19 | 19 | mod synckit_adversarial; | |
| 20 | 20 | mod pages; | |
| 21 | 21 | mod analytics; | |
| 22 | + | mod creator_tier_comp; | |
| 22 | 23 | mod promo_codes_discount; | |
| 23 | 24 | mod promo_codes_free_access; | |
| 24 | 25 | mod exports; |