Skip to main content

max / makenotwork

Unify discount codes and download codes into promo codes system Merge discount_codes and download_codes tables into a single promo_codes table supporting three purposes: discount, free_access, and free_trial. Add free trial support for subscription tiers via Stripe trial_period_days. - New migration creates promo_codes table with CHECK constraints, migrates existing data, and drops old tables - Unified API at /api/promo-codes (create, list, delete, claim) - Subscription checkout accepts promo codes for free trial periods - Dashboard shows all code types in one list with scope names and expiry - Consistent "promo code" terminology across all user-facing surfaces - Free access codes keep lowercase word format, discount/trial codes uppercase Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-09 19:19 UTC
Commit: 1d1f998c0146b263bebfd242ddb9808a64b608ca
Parent: 67ba4df
33 files changed, +1215 insertions, -1189 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.1.5"
3 + version = "0.1.6"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -16,7 +16,7 @@
16 16 Webhook, Event, EventObject, EventType,
17 17 };
18 18 use crate::config::StripeConfig;
19 - use crate::db::{DiscountCodeId, ItemId, ProjectId, SubscriptionTierId, UserId};
19 + use crate::db::{ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId};
20 20 use crate::error::{AppError, Result};
21 21
22 22 type HmacSha256 = Hmac<Sha256>;
@@ -31,7 +31,7 @@
31 31 pub item_id: ItemId,
32 32 pub success_url: &'a str,
33 33 pub cancel_url: &'a str,
34 - pub discount_code_id: Option<DiscountCodeId>,
34 + pub promo_code_id: Option<PromoCodeId>,
35 35 }
36 36
37 37 /// Parameters for creating a subscription Checkout Session.
@@ -43,6 +43,8 @@
43 43 pub tier_id: SubscriptionTierId,
44 44 pub success_url: &'a str,
45 45 pub cancel_url: &'a str,
46 + pub trial_days: Option<i32>,
47 + pub promo_code_id: Option<PromoCodeId>,
46 48 }
47 49
48 50 /// Stripe client wrapper for payment operations
@@ -143,8 +145,8 @@
143 145 metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
144 146 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
145 147 metadata.insert("item_id".to_string(), checkout.item_id.to_string());
146 - if let Some(dc_id) = checkout.discount_code_id {
147 - metadata.insert("discount_code_id".to_string(), dc_id.to_string());
148 + if let Some(pc_id) = checkout.promo_code_id {
149 + metadata.insert("promo_code_id".to_string(), pc_id.to_string());
148 150 }
149 151 params.metadata = Some(metadata);
150 152
@@ -317,8 +319,19 @@
317 319 metadata.insert("project_id".to_string(), sub.project_id.to_string());
318 320 metadata.insert("tier_id".to_string(), sub.tier_id.to_string());
319 321 metadata.insert("checkout_type".to_string(), "subscription".to_string());
322 + if let Some(pc_id) = sub.promo_code_id {
323 + metadata.insert("promo_code_id".to_string(), pc_id.to_string());
324 + }
320 325 params.metadata = Some(metadata);
321 326
327 + // Apply free trial period if specified
328 + if let Some(days) = sub.trial_days {
329 + params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData {
330 + trial_period_days: Some(days as u32),
331 + ..Default::default()
332 + });
333 + }
334 +
322 335 let session = CheckoutSession::create(&connected_client, params)
323 336 .await
324 337 .map_err(|e| {
@@ -340,8 +353,8 @@
340 353 pub seller_id: UserId,
341 354 /// UUID of the item being purchased.
342 355 pub item_id: ItemId,
343 - /// UUID of the discount code used, if any.
344 - pub discount_code_id: Option<DiscountCodeId>,
356 + /// UUID of the promo code used, if any.
357 + pub promo_code_id: Option<PromoCodeId>,
345 358 }
346 359
347 360 impl CheckoutMetadata {
@@ -368,14 +381,14 @@
368 381 .map(ItemId::from)
369 382 .map_err(|_| AppError::BadRequest("Invalid item_id format".to_string()))?;
370 383
371 - let discount_code_id: Option<DiscountCodeId> = metadata.get("discount_code_id")
372 - .and_then(|v| v.parse::<uuid::Uuid>().ok().map(DiscountCodeId::from));
384 + let promo_code_id: Option<PromoCodeId> = metadata.get("promo_code_id")
385 + .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
373 386
374 387 Ok(CheckoutMetadata {
375 388 buyer_id,
376 389 seller_id,
377 390 item_id,
378 - discount_code_id,
391 + promo_code_id,
379 392 })
380 393 }
381 394 }
@@ -396,6 +409,7 @@
396 409 pub subscriber_id: UserId,
397 410 pub project_id: ProjectId,
398 411 pub tier_id: SubscriptionTierId,
412 + pub promo_code_id: Option<PromoCodeId>,
399 413 }
400 414
401 415 impl SubscriptionCheckoutMetadata {
@@ -422,10 +436,14 @@
422 436 .map(SubscriptionTierId::from)
423 437 .map_err(|_| AppError::BadRequest("Invalid tier_id format".to_string()))?;
424 438
439 + let promo_code_id: Option<PromoCodeId> = metadata.get("promo_code_id")
440 + .and_then(|v| v.parse::<uuid::Uuid>().ok().map(PromoCodeId::from));
441 +
425 442 Ok(SubscriptionCheckoutMetadata {
426 443 subscriber_id,
427 444 project_id,
428 445 tier_id,
446 + promo_code_id,
429 447 })
430 448 }
431 449 }
@@ -78,6 +78,22 @@
78 78 Fixed => "fixed",
79 79 });
80 80
81 + // ── Promo codes ──
82 +
83 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84 + #[serde(rename_all = "snake_case")]
85 + pub enum CodePurpose {
86 + Discount,
87 + FreeAccess,
88 + FreeTrial,
89 + }
90 +
91 + impl_str_enum!(CodePurpose {
92 + Discount => "discount",
93 + FreeAccess => "free_access",
94 + FreeTrial => "free_trial",
95 + });
96 +
81 97 // ── Waitlist ──
82 98
83 99 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -407,6 +423,14 @@
407 423 assert!("invalid".parse::<DiscoverSort>().is_err());
408 424 }
409 425
426 + #[test]
427 + fn code_purpose_round_trip() {
428 + assert_eq!(CodePurpose::Discount.to_string(), "discount");
429 + assert_eq!("free_access".parse::<CodePurpose>().unwrap(), CodePurpose::FreeAccess);
430 + assert_eq!("free_trial".parse::<CodePurpose>().unwrap(), CodePurpose::FreeTrial);
431 + assert!("bogus".parse::<CodePurpose>().is_err());
432 + }
433 +
410 434 #[test]
411 435 fn serde_json_round_trip() {
412 436 let dt = DiscountType::Percentage;
@@ -145,8 +145,7 @@
145 145 LicenseKeyId,
146 146 LicenseActivationId,
147 147 TagId,
148 - DownloadCodeId,
149 - DiscountCodeId,
148 + PromoCodeId,
150 149 FollowId,
151 150 SubscriptionTierId,
152 151 SubscriptionId,
@@ -22,8 +22,7 @@
22 22 pub(crate) mod license_keys;
23 23 pub(crate) mod synckit;
24 24 pub(crate) mod oauth;
25 - pub(crate) mod discount_codes;
26 - pub(crate) mod download_codes;
25 + pub(crate) mod promo_codes;
27 26 pub(crate) mod follows;
28 27 pub(crate) mod subscriptions;
29 28 pub(crate) mod tags;
@@ -902,20 +902,34 @@
902 902 pub count: i64,
903 903 }
904 904
905 - /// A creator-generated code that grants free access to an item.
905 + /// A unified promo code (discount, free access, or free trial).
906 906 #[derive(Debug, Clone, FromRow, Serialize)]
907 - pub struct DbDownloadCode {
907 + pub struct DbPromoCode {
908 908 /// Database primary key.
909 - pub id: DownloadCodeId,
910 - /// Item this code grants access to.
911 - pub item_id: ItemId,
912 - /// Creator who generated this code.
913 - pub created_by_id: UserId,
914 - /// The code string (word-word-word-word-word format).
915 - pub code: KeyCode,
916 - /// Maximum number of times this code can be used (NULL = unlimited).
909 + pub id: PromoCodeId,
910 + /// Creator who owns this code.
911 + pub creator_id: UserId,
912 + /// The code string entered by buyers.
913 + pub code: String,
914 + /// What this code does: discount, free_access, or free_trial.
915 + pub code_purpose: super::CodePurpose,
916 + /// Discount type (percentage or fixed). Present when purpose = discount.
917 + pub discount_type: Option<super::DiscountType>,
918 + /// Discount amount: percentage value or cents. Present when purpose = discount.
919 + pub discount_value: Option<i32>,
920 + /// Minimum item price (cents) for discount codes to apply.
921 + pub min_price_cents: i32,
922 + /// Number of free trial days. Present when purpose = free_trial.
923 + pub trial_days: Option<i32>,
924 + /// Restrict to a specific item.
925 + pub item_id: Option<ItemId>,
926 + /// Restrict to a specific project.
927 + pub project_id: Option<ProjectId>,
928 + /// Restrict to a specific subscription tier.
929 + pub tier_id: Option<SubscriptionTierId>,
930 + /// Maximum number of uses (NULL = unlimited).
917 931 pub max_uses: Option<i32>,
918 - /// Current number of claims against this code.
932 + /// Current number of times this code has been used.
919 933 pub use_count: i32,
920 934 /// When this code expires (NULL = never).
921 935 pub expires_at: Option<DateTime<Utc>>,
@@ -923,33 +937,28 @@
923 937 pub created_at: DateTime<Utc>,
924 938 }
925 939
926 - /// A creator-generated discount code that reduces an item's price.
927 - #[derive(Debug, Clone, FromRow, Serialize)]
928 - pub struct DbDiscountCode {
929 - /// Database primary key.
930 - pub id: DiscountCodeId,
931 - /// Creator who owns this code.
932 - pub seller_id: UserId,
933 - /// The code string entered by buyers.
940 + /// Promo code with joined item/project names for dashboard display.
941 + #[derive(Debug, Clone, FromRow)]
942 + pub struct DbPromoCodeWithNames {
943 + pub id: PromoCodeId,
944 + pub creator_id: UserId,
934 945 pub code: String,
935 - /// Percentage (1-100) or fixed (cents to subtract).
936 - pub discount_type: super::DiscountType,
937 - /// Discount amount: percentage value or cents.
938 - pub discount_value: i32,
939 - /// Minimum item price (cents) for this code to apply.
946 + pub code_purpose: super::CodePurpose,
947 + pub discount_type: Option<super::DiscountType>,
948 + pub discount_value: Option<i32>,
940 949 pub min_price_cents: i32,
941 - /// Maximum number of uses (NULL = unlimited).
942 - pub max_uses: Option<i32>,
943 - /// Current number of times this code has been used.
944 - pub use_count: i32,
945 - /// When this code expires (NULL = never).
946 - pub expires_at: Option<DateTime<Utc>>,
947 - /// Restrict to a specific item (NULL = any item by this seller).
950 + pub trial_days: Option<i32>,
948 951 pub item_id: Option<ItemId>,
949 - /// Restrict to a specific project (NULL = any project by this seller).
950 952 pub project_id: Option<ProjectId>,
951 - /// When this code was created.
953 + pub tier_id: Option<SubscriptionTierId>,
954 + pub max_uses: Option<i32>,
955 + pub use_count: i32,
956 + pub expires_at: Option<DateTime<Utc>>,
952 957 pub created_at: DateTime<Utc>,
958 + /// Joined item title, if item-scoped.
959 + pub item_title: Option<String>,
960 + /// Joined project title, if project-scoped.
961 + pub project_title: Option<String>,
953 962 }
954 963
955 964 // ── Content Insertion models ──
@@ -4,7 +4,7 @@
4 4 use sqlx::PgPool;
5 5
6 6 use super::models::*;
7 - use super::{DiscountCodeId, DownloadCodeId, ItemId, ProjectId, UserId};
7 + use super::{ItemId, ProjectId, PromoCodeId, UserId};
8 8 use crate::error::Result;
9 9
10 10 /// Parameters for creating a pending Stripe checkout transaction.
@@ -181,23 +181,23 @@
181 181 Ok(result.rows_affected() > 0)
182 182 }
183 183
184 - /// Atomically increment a discount code's use count and claim a free item.
184 + /// Atomically increment a promo code's use count and claim a free item.
185 185 ///
186 186 /// Wraps both operations in a single transaction so the use_count doesn't
187 187 /// drift if the claim fails. Returns `(code_accepted, item_claimed)`:
188 - /// - `code_accepted = false` → discount code hit its usage limit (nothing changed)
188 + /// - `code_accepted = false` → promo code hit its usage limit (nothing changed)
189 189 /// - `item_claimed = false` → user already owns the item (code was still consumed)
190 - pub async fn claim_free_with_discount_code(
190 + pub async fn claim_free_with_promo_code(
191 191 pool: &PgPool,
192 - discount_code_id: DiscountCodeId,
192 + promo_code_id: PromoCodeId,
193 193 params: &ClaimParams<'_>,
194 194 ) -> Result<(bool, bool)> {
195 195 let mut tx = pool.begin().await?;
196 196
197 197 let result = sqlx::query(
198 - "UPDATE discount_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
198 + "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
199 199 )
200 - .bind(discount_code_id)
200 + .bind(promo_code_id)
201 201 .execute(&mut *tx)
202 202 .await?;
203 203
@@ -232,56 +232,6 @@
232 232 Ok((true, claimed))
233 233 }
234 234
235 - /// Atomically increment a download code's use count and claim a free item.
236 - ///
237 - /// Same transactional guarantee as [`claim_free_with_discount_code`].
238 - /// Download codes never share contact info; `params.share_contact` is ignored
239 - /// and hardcoded to `false`.
240 - pub async fn claim_free_with_download_code(
241 - pool: &PgPool,
242 - download_code_id: DownloadCodeId,
243 - params: &ClaimParams<'_>,
244 - ) -> Result<(bool, bool)> {
245 - let mut tx = pool.begin().await?;
246 -
247 - let result = sqlx::query(
248 - "UPDATE download_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
249 - )
250 - .bind(download_code_id)
251 - .execute(&mut *tx)
252 - .await?;
253 -
254 - if result.rows_affected() == 0 {
255 - tx.rollback().await?;
256 - return Ok((false, false));
257 - }
258 -
259 - let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id);
260 - let result = sqlx::query(
261 - r#"
262 - INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact)
263 - VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7)
264 - ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' DO NOTHING
265 - "#,
266 - )
267 - .bind(params.buyer_id)
268 - .bind(params.seller_id)
269 - .bind(params.item_id)
270 - .bind(&claim_id)
271 - .bind(params.item_title)
272 - .bind(params.seller_username)
273 - .bind(false) // download codes don't share contact
274 - .execute(&mut *tx)
275 - .await?;
276 -
277 - let claimed = result.rows_affected() > 0;
278 - if claimed {
279 - crate::db::items::increment_sales_count(&mut *tx, params.item_id).await?;
280 - }
281 - tx.commit().await?;
282 - Ok((true, claimed))
283 - }
284 -
285 235 /// Get items purchased by a user, including any associated license key.
286 236 ///
287 237 /// Reads from the `purchases` VIEW (which filters `transactions` to