Skip to main content

max / makenotwork

Add creator-tier comp codes via free-trial promo codes Plumb free_trial promo codes into the creator-tier checkout so an operator can comp a creator subscription (e.g. alpha-tester 6-month grants): the code grants a Stripe trial with no card collected up front, after which the subscription rolls to the price chosen at checkout (founder price during the founder window). No silent charge — continuing is an explicit opt-in. - create_creator_tier_checkout_session takes trial_days; sets trial_period_days + payment_method_collection=if_required (trait, impl, and test mock updated) - CreatorTierForm.promo_code with validate/reserve/release-on-failure - db::promo_codes::get_platform_trial_code_by_code (platform-wide + free_trial scope, so comp codes can't collide with creator codes) - admin route POST /api/admin/comp-codes/create to mint codes - 5 integration tests: mint + row contract, zero-days/non-admin reject, end-to-end redemption (180-day trial reaches Stripe, one use reserved), expired-code rejection (no session, no use burned) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 03:41 UTC
Commit: 4d22775aab7cbc7de71d43753da9c1b7b7d99453
Parent: 9440d61
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;