Skip to main content

max / makenotwork

Enforce once-per-individual on comp code redemption A reusable comp code (no global cap) should grant each distinct person one trial, not unlimited trials to whoever reuses it. Add a per-user redemption ledger so a creator can share one code freely while each recipient gets a single free month. - migration 142: promo_code_redemptions (promo_code_id, user_id) with a composite PK enforcing one redemption per (code, user) - db::promo_codes::try_record_redemption / remove_redemption - creator-tier checkout records the redemption before reserving the global use; a repeat by the same user is rejected ("You have already used this code"), and both records roll back if the Stripe call fails - use_count now equals distinct redeemers, so the dashboard Uses column is the count of distinct people; help text documents the unique-vs-reusable patterns - test: same user redeeming twice is rejected while a second user succeeds Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 19:11 UTC
Commit: c221672b67f061d188316567f9b2b5c03b5826c5
Parent: b723f5e
5 files changed, +135 insertions, -6 deletions
@@ -0,0 +1,17 @@
1 + -- Per-individual redemption ledger for promo codes.
2 + --
3 + -- Enforces "once per individual" on reusable comp codes: a (promo_code_id,
4 + -- user_id) row is inserted when a user redeems a code, and the UNIQUE
5 + -- constraint rejects a second redemption of the same code by the same user.
6 + -- The global `promo_codes.use_count` / `max_uses` still bound total uses;
7 + -- this table adds the per-user guarantee and is the source of truth for how
8 + -- many distinct individuals redeemed a code.
9 + CREATE TABLE promo_code_redemptions (
10 + promo_code_id UUID NOT NULL REFERENCES promo_codes(id) ON DELETE CASCADE,
11 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
12 + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
13 + PRIMARY KEY (promo_code_id, user_id)
14 + );
15 +
16 + -- Look up "how many distinct users redeemed this code" quickly.
17 + CREATE INDEX idx_promo_code_redemptions_code ON promo_code_redemptions (promo_code_id);
@@ -476,6 +476,43 @@ pub async fn get_platform_trial_code_by_code(
476 476 Ok(promo_code)
477 477 }
478 478
479 + /// Record that `user_id` redeemed `code_id`, enforcing once-per-individual.
480 + ///
481 + /// Returns `true` if this is the user's first redemption of the code (row
482 + /// inserted), `false` if they have already redeemed it (the `(code, user)`
483 + /// primary key conflicts). Atomic — the conflict resolution closes the
484 + /// double-submit race.
485 + #[tracing::instrument(skip_all)]
486 + pub async fn try_record_redemption(
487 + pool: &PgPool,
488 + code_id: PromoCodeId,
489 + user_id: UserId,
490 + ) -> Result<bool> {
491 + let result = sqlx::query(
492 + "INSERT INTO promo_code_redemptions (promo_code_id, user_id) \
493 + VALUES ($1, $2) ON CONFLICT DO NOTHING",
494 + )
495 + .bind(code_id)
496 + .bind(user_id)
497 + .execute(pool)
498 + .await?;
499 +
500 + Ok(result.rows_affected() > 0)
501 + }
502 +
503 + /// Remove a per-user redemption record. Used to roll back a reservation when a
504 + /// later step (usage-limit reservation or the Stripe call) fails after the
505 + /// redemption row was inserted.
506 + #[tracing::instrument(skip_all)]
507 + pub async fn remove_redemption(pool: &PgPool, code_id: PromoCodeId, user_id: UserId) -> Result<()> {
508 + sqlx::query("DELETE FROM promo_code_redemptions WHERE promo_code_id = $1 AND user_id = $2")
509 + .bind(code_id)
510 + .bind(user_id)
511 + .execute(pool)
512 + .await?;
513 + Ok(())
514 + }
515 +
479 516 /// List all creator-tier comp codes (platform-wide free-trial), newest first.
480 517 /// Powers the admin comp-codes dashboard. Capped at 500.
481 518 #[tracing::instrument(skip_all)]
@@ -272,13 +272,23 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
272 272 }
273 273 }
274 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.
275 + // Reserve the code: per-individual first (once per person), then the global
276 + // usage cap. Both are rolled back if the Stripe call below fails.
277 277 if let Some(pc_id) = promo_code_id {
278 + // Once-per-individual: a repeat redemption by the same user is rejected,
279 + // so a reusable code shared by a creator grants each person one trial.
280 + let first_time = db::promo_codes::try_record_redemption(&state.db, pc_id, user.id)
281 + .await
282 + .context("record comp code redemption at creator-tier checkout")?;
283 + if !first_time {
284 + return Err(AppError::BadRequest("You have already used this code.".to_string()));
285 + }
286 + // Global usage cap (max_uses). The WHERE clause re-checks the limit.
278 287 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
279 288 .await
280 289 .context("reserve comp code use at creator-tier checkout")?;
281 290 if !reserved {
291 + db::promo_codes::remove_redemption(&state.db, pc_id, user.id).await.ok();
282 292 return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
283 293 }
284 294 }
@@ -295,6 +305,7 @@ pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
295 305 Err(e) => {
296 306 if let Some(pc_id) = promo_code_id {
297 307 db::promo_codes::release_use_count(&state.db, pc_id).await.ok();
308 + db::promo_codes::remove_redemption(&state.db, pc_id, user.id).await.ok();
298 309 }
299 310 return Err(e);
300 311 }
@@ -15,11 +15,18 @@
15 15 <h1 class="page-title">Comp codes</h1>
16 16
17 17 <p class="text-sm dimmed">
18 - Mint a free-trial code redeemable at creator-tier checkout. Name it after the
19 - recipient (for example ALPHA-JAMIE) so the status below tells you whether that
20 - person has redeemed. No card is collected up front; after the trial the
21 - subscription rolls to the founder price only if the recipient opts in.
18 + Mint a free-trial code redeemable at creator-tier checkout. No card is collected
19 + up front; after the trial the subscription rolls to the founder price only if the
20 + recipient opts in. Two patterns:
22 21 </p>
22 + <ul class="text-sm dimmed">
23 + <li><strong>Unique code per creator</strong> — name it after the recipient (for
24 + example ALPHA-JAMIE) and set Max uses to 1. The status shows Unused until
25 + they redeem, then Redeemed.</li>
26 + <li><strong>Reusable code to share</strong> — leave Max uses blank. Anyone can
27 + redeem it, but each individual only once; the Uses count is the number of
28 + distinct people who have redeemed.</li>
29 + </ul>
23 30
24 31 <div class="lottery-form">
25 32 <form hx-post="/api/admin/comp-codes/create" hx-target="#comp-codes-table" hx-swap="innerHTML">
@@ -124,6 +124,63 @@ async fn comp_code_redeemed_at_creator_tier_checkout() {
124 124 assert_eq!(use_count, 1, "redemption should reserve one use");
125 125 }
126 126
127 + /// A reusable comp code (no global cap) grants each distinct individual one
128 + /// trial: the same user redeeming twice is rejected, a different user succeeds.
129 + #[tokio::test]
130 + async fn reusable_comp_code_enforces_once_per_individual() {
131 + let mut h = TestHarness::with_creator_tier_checkout().await;
132 +
133 + // Owner for the FK, then a reusable 1-month code with no global cap.
134 + let owner = h.signup("codeowner", "codeowner@test.com", "password123").await;
135 + sqlx::query(
136 + "INSERT INTO promo_codes \
137 + (creator_id, code, code_purpose, min_price_cents, trial_days, is_platform_wide) \
138 + VALUES ($1, 'SHARE1MO', 'free_trial', 0, 30, true)",
139 + )
140 + .bind(*owner)
141 + .execute(&h.db)
142 + .await
143 + .expect("seed reusable code");
144 +
145 + // User A redeems once: succeeds.
146 + h.signup("share_a", "share_a@test.com", "password123").await;
147 + let r1 = h.client.post_form("/stripe/creator-tier", "tier=everything&promo_code=share1mo").await;
148 + assert!(r1.status.is_redirection() || r1.status.is_success(), "A first redeem: {} {}", r1.status, r1.text);
149 +
150 + // User A redeems the SAME code again: rejected (once per individual).
151 + let r2 = h.client.post_form("/stripe/creator-tier", "tier=everything&promo_code=share1mo").await;
152 + assert_eq!(r2.status, 400, "A's repeat redeem should be rejected: {}", r2.text);
153 + assert!(
154 + r2.text.to_lowercase().contains("already used"),
155 + "rejection should explain the repeat: {}",
156 + r2.text
157 + );
158 +
159 + // A different individual redeems the same code: succeeds.
160 + h.signup("share_b", "share_b@test.com", "password123").await;
161 + let r3 = h.client.post_form("/stripe/creator-tier", "tier=everything&promo_code=share1mo").await;
162 + assert!(r3.status.is_redirection() || r3.status.is_success(), "B redeem: {} {}", r3.status, r3.text);
163 +
164 + // Two distinct individuals redeemed; the repeat did not count.
165 + let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'SHARE1MO'")
166 + .fetch_one(&h.db)
167 + .await
168 + .unwrap();
169 + assert_eq!(use_count, 2, "exactly two distinct redeemers");
170 + let redemptions: i64 = sqlx::query_scalar(
171 + "SELECT COUNT(*) FROM promo_code_redemptions r \
172 + JOIN promo_codes p ON p.id = r.promo_code_id WHERE p.code = 'SHARE1MO'",
173 + )
174 + .fetch_one(&h.db)
175 + .await
176 + .unwrap();
177 + assert_eq!(redemptions, 2, "one redemption row per distinct user");
178 +
179 + // The 30-day trial reached Stripe for both successful redemptions only.
180 + let trials = h.mock_stripe.as_ref().unwrap().creator_tier_trial_days();
181 + assert_eq!(trials, vec![Some(30), Some(30)], "both grants pass a 30-day trial; the rejected repeat does not");
182 + }
183 +
127 184 /// An expired comp code is rejected: no Stripe session, no use burned.
128 185 #[tokio::test]
129 186 async fn expired_comp_code_rejected_at_creator_tier_checkout() {