max / makenotwork
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() { |