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, +1214 insertions, -1188 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
@@ -0,0 +1,55 @@
1 + -- Unified promo_codes table replacing discount_codes + download_codes.
2 + -- Supports three code purposes:
3 + -- discount — percentage or fixed price reduction
4 + -- free_access — grants free access to item (replaces download_codes)
5 + -- free_trial — N days free on a subscription tier
6 +
7 + CREATE TABLE promo_codes (
8 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
9 + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
10 + code TEXT NOT NULL,
11 + code_purpose TEXT NOT NULL CHECK (code_purpose IN ('discount', 'free_access', 'free_trial')),
12 + -- Discount fields (required when purpose = 'discount')
13 + discount_type TEXT CHECK (discount_type IN ('percentage', 'fixed')),
14 + discount_value INT,
15 + min_price_cents INT NOT NULL DEFAULT 0,
16 + -- Trial fields (required when purpose = 'free_trial')
17 + trial_days INT,
18 + -- Scope
19 + item_id UUID REFERENCES items(id) ON DELETE CASCADE,
20 + project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
21 + tier_id UUID REFERENCES subscription_tiers(id) ON DELETE CASCADE,
22 + -- Usage
23 + max_uses INT,
24 + use_count INT NOT NULL DEFAULT 0,
25 + expires_at TIMESTAMPTZ,
26 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
27 + -- Integrity
28 + CONSTRAINT chk_discount_fields CHECK (
29 + code_purpose != 'discount' OR (discount_type IS NOT NULL AND discount_value > 0)
30 + ),
31 + CONSTRAINT chk_trial_fields CHECK (
32 + code_purpose != 'free_trial' OR (trial_days IS NOT NULL AND trial_days > 0)
33 + )
34 + );
35 +
36 + CREATE UNIQUE INDEX idx_promo_codes_creator_code ON promo_codes(creator_id, upper(code));
37 + CREATE INDEX idx_promo_codes_item ON promo_codes(item_id);
38 + CREATE INDEX idx_promo_codes_project ON promo_codes(project_id);
39 + CREATE INDEX idx_promo_codes_tier ON promo_codes(tier_id);
40 +
41 + -- Migrate existing data
42 + INSERT INTO promo_codes (id, creator_id, code, code_purpose, discount_type, discount_value,
43 + min_price_cents, item_id, project_id, max_uses, use_count, expires_at, created_at)
44 + SELECT id, seller_id, code, 'discount', discount_type, discount_value,
45 + min_price_cents, item_id, project_id, max_uses, use_count, expires_at, created_at
46 + FROM discount_codes;
47 +
48 + INSERT INTO promo_codes (id, creator_id, code, code_purpose, item_id, max_uses,
49 + use_count, expires_at, created_at)
50 + SELECT id, created_by_id, code, 'free_access', item_id, max_uses,
51 + use_count, expires_at, created_at
52 + FROM download_codes;
53 +
54 + DROP TABLE download_codes;
55 + DROP TABLE discount_codes;
@@ -1,172 +0,0 @@
1 - //! Discount code management: creation, validation, usage tracking, and deletion.
2 -
3 - use sqlx::PgPool;
4 -
5 - use super::enums::DiscountType;
6 - use super::models::*;
7 - use super::{DiscountCodeId, ItemId, ProjectId, UserId};
8 - use crate::error::Result;
9 -
10 - /// Create a new discount code for a seller.
11 - #[allow(clippy::too_many_arguments)]
12 - pub async fn create_discount_code(
13 - pool: &PgPool,
14 - seller_id: UserId,
15 - code: &str,
16 - discount_type: DiscountType,
17 - discount_value: i32,
18 - min_price_cents: i32,
19 - max_uses: Option<i32>,
20 - expires_at: Option<chrono::DateTime<chrono::Utc>>,
21 - item_id: Option<ItemId>,
22 - project_id: Option<ProjectId>,
23 - ) -> Result<DbDiscountCode> {
24 - let discount_code = sqlx::query_as::<_, DbDiscountCode>(
25 - r#"
26 - INSERT INTO discount_codes (seller_id, code, discount_type, discount_value, min_price_cents, max_uses, expires_at, item_id, project_id)
27 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
28 - RETURNING *
29 - "#,
30 - )
31 - .bind(seller_id)
32 - .bind(code)
33 - .bind(discount_type)
34 - .bind(discount_value)
35 - .bind(min_price_cents)
36 - .bind(max_uses)
37 - .bind(expires_at)
38 - .bind(item_id)
39 - .bind(project_id)
40 - .fetch_one(pool)
41 - .await?;
42 -
43 - Ok(discount_code)
44 - }
45 -
46 - /// List all discount codes for a seller, newest first. Capped at 500.
47 - pub async fn get_discount_codes_by_seller(pool: &PgPool, seller_id: UserId) -> Result<Vec<DbDiscountCode>> {
48 - let codes = sqlx::query_as::<_, DbDiscountCode>(
49 - "SELECT * FROM discount_codes WHERE seller_id = $1 ORDER BY created_at DESC LIMIT 500",
50 - )
51 - .bind(seller_id)
52 - .fetch_all(pool)
53 - .await?;
54 -
55 - Ok(codes)
56 - }
57 -
58 - /// List all discount codes scoped to a project, newest first. Capped at 500.
59 - pub async fn get_discount_codes_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbDiscountCode>> {
60 - let codes = sqlx::query_as::<_, DbDiscountCode>(
61 - "SELECT * FROM discount_codes WHERE project_id = $1 ORDER BY created_at DESC LIMIT 500",
62 - )
63 - .bind(project_id)
64 - .fetch_all(pool)
65 - .await?;
66 -
67 - Ok(codes)
68 - }
69 -
70 - /// Fetch a discount code by primary key.
71 - pub async fn get_discount_code_by_id(pool: &PgPool, id: DiscountCodeId) -> Result<Option<DbDiscountCode>> {
72 - let code = sqlx::query_as::<_, DbDiscountCode>(
73 - "SELECT * FROM discount_codes WHERE id = $1",
74 - )
75 - .bind(id)
76 - .fetch_optional(pool)
77 - .await?;
78 -
79 - Ok(code)
80 - }
81 -
82 - /// Look up a discount code by seller ID and code string.
83 - pub async fn get_discount_code_by_seller_and_code(
84 - pool: &PgPool,
85 - seller_id: UserId,
86 - code: &str,
87 - ) -> Result<Option<DbDiscountCode>> {
88 - let discount_code = sqlx::query_as::<_, DbDiscountCode>(
89 - "SELECT * FROM discount_codes WHERE seller_id = $1 AND code = $2",
90 - )
91 - .bind(seller_id)
92 - .bind(code)
93 - .fetch_optional(pool)
94 - .await?;
95 -
96 - Ok(discount_code)
97 - }
98 -
99 - /// Atomically increment use_count, respecting the max_uses limit.
100 - ///
101 - /// Returns `true` if the increment succeeded, `false` if the code has already
102 - /// reached its usage limit. The `WHERE` clause enforces the limit at the
103 - /// database level, preventing TOCTOU races where concurrent requests both
104 - /// read `use_count < max_uses` and both increment past the limit.
105 - ///
106 - /// Accepts any sqlx executor (`&PgPool`, `&mut Transaction`, etc.) so callers
107 - /// can include this in a larger transaction when needed.
108 - pub async fn try_increment_discount_code_use_count<'e>(
109 - executor: impl sqlx::PgExecutor<'e>,
110 - id: DiscountCodeId,
111 - ) -> Result<bool> {
112 - let result = sqlx::query(
113 - "UPDATE discount_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
114 - )
115 - .bind(id)
116 - .execute(executor)
117 - .await?;
118 -
119 - Ok(result.rows_affected() > 0)
120 - }
121 -
122 - /// Delete a discount code permanently.
123 - pub async fn delete_discount_code(pool: &PgPool, id: DiscountCodeId) -> Result<()> {
124 - sqlx::query("DELETE FROM discount_codes WHERE id = $1")
125 - .bind(id)
126 - .execute(pool)
127 - .await?;
128 -
129 - Ok(())
130 - }
131 -
132 - /// Apply a discount to a price, returning the discounted price in cents (minimum 0).
133 - pub fn apply_discount(price_cents: i32, discount_type: DiscountType, discount_value: i32) -> i32 {
134 - match discount_type {
135 - DiscountType::Percentage => {
136 - let discount = (price_cents as i64 * discount_value as i64) / 100;
137 - (price_cents - discount as i32).max(0)
138 - }
139 - DiscountType::Fixed => (price_cents - discount_value).max(0),
140 - }
141 - }
142 -
143 - #[cfg(test)]
144 - mod tests {
145 - use super::*;
146 -
147 - #[test]
148 - fn percentage_discount_50() {
149 - assert_eq!(apply_discount(1000, DiscountType::Percentage, 50), 500);
150 - }
151 -
152 - #[test]
153 - fn percentage_discount_100() {
154 - assert_eq!(apply_discount(1000, DiscountType::Percentage, 100), 0);
155 - }
156 -
157 - #[test]
158 - fn percentage_discount_10() {
159 - // 999 * 10 / 100 = 99 (integer), 999 - 99 = 900
160 - assert_eq!(apply_discount(999, DiscountType::Percentage, 10), 900);
161 - }
162 -
163 - #[test]
164 - fn fixed_discount() {
165 - assert_eq!(apply_discount(1000, DiscountType::Fixed, 300), 700);
166 - }
167 -
168 - #[test]
169 - fn fixed_discount_exceeds_price() {
170 - assert_eq!(apply_discount(100, DiscountType::Fixed, 500), 0);
171 - }
172 - }
@@ -1,81 +0,0 @@
1 - //! Download code management: generation, validation, claiming, and revocation.
2 -
3 - use sqlx::PgPool;
4 -
5 - use super::models::*;
6 - use super::validated_types::KeyCode;
7 - use super::{DownloadCodeId, ItemId, UserId};
8 - use crate::error::Result;
9 -
10 - /// Create a new download code for an item.
11 - pub async fn create_download_code(
12 - pool: &PgPool,
13 - item_id: ItemId,
14 - created_by_id: UserId,
15 - code: &KeyCode,
16 - max_uses: Option<i32>,
17 - expires_at: Option<chrono::DateTime<chrono::Utc>>,
18 - ) -> Result<DbDownloadCode> {
19 - let download_code = sqlx::query_as::<_, DbDownloadCode>(
20 - r#"
21 - INSERT INTO download_codes (item_id, created_by_id, code, max_uses, expires_at)
22 - VALUES ($1, $2, $3, $4, $5)
23 - RETURNING *
24 - "#,
25 - )
26 - .bind(item_id)
27 - .bind(created_by_id)
28 - .bind(code)
29 - .bind(max_uses)
30 - .bind(expires_at)
31 - .fetch_one(pool)
32 - .await?;
33 -
34 - Ok(download_code)
35 - }
36 -
37 - /// List all download codes for an item, newest first. Capped at 500.
38 - pub async fn get_download_codes_by_item(pool: &PgPool, item_id: ItemId) -> Result<Vec<DbDownloadCode>> {
39 - let codes = sqlx::query_as::<_, DbDownloadCode>(
40 - "SELECT * FROM download_codes WHERE item_id = $1 ORDER BY created_at DESC LIMIT 500",
41 - )
42 - .bind(item_id)
43 - .fetch_all(pool)
44 - .await?;
45 -
46 - Ok(codes)
47 - }
48 -
49 - /// Fetch a download code by primary key.
50 - pub async fn get_download_code_by_id(pool: &PgPool, id: DownloadCodeId) -> Result<Option<DbDownloadCode>> {
51 - let code = sqlx::query_as::<_, DbDownloadCode>(
52 - "SELECT * FROM download_codes WHERE id = $1",
53 - )
54 - .bind(id)
55 - .fetch_optional(pool)
56 - .await?;
57 -
58 - Ok(code)
59 - }
60 -
61 - /// Look up a download code by its code string.
62 - pub async fn get_download_code_by_code(pool: &PgPool, code: &KeyCode) -> Result<Option<DbDownloadCode>> {
63 - let download_code = sqlx::query_as::<_, DbDownloadCode>(
64 - "SELECT * FROM download_codes WHERE code = $1",
65 - )
66 - .bind(code)
67 - .fetch_optional(pool)
68 - .await?;
69 -
70 - Ok(download_code)
71 - }
72 -
73 - /// Delete a download code permanently.
74 - pub async fn delete_download_code(pool: &PgPool, id: DownloadCodeId) -> Result<()> {
75 - sqlx::query("DELETE FROM download_codes WHERE id = $1")
76 - .bind(id)
77 - .execute(pool)
78 - .await?;
79 -
80 - Ok(())
81 - }
@@ -78,6 +78,22 @@ impl_str_enum!(DiscountType {
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)]
@@ -408,6 +424,14 @@ mod tests {
408 424 }
409 425
410 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 +
434 + #[test]
411 435 fn serde_json_round_trip() {
412 436 let dt = DiscountType::Percentage;
413 437 let json = serde_json::to_string(&dt).unwrap();
@@ -145,8 +145,7 @@ define_pg_uuid_id!(
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 @@ pub(crate) mod blog_posts;
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 @@ pub struct DbTagCount {
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 @@ pub struct DbDownloadCode {
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).
950 + pub trial_days: Option<i32>,
951 + pub item_id: Option<ItemId>,
952 + pub project_id: Option<ProjectId>,
953 + pub tier_id: Option<SubscriptionTierId>,
942 954 pub max_uses: Option<i32>,
943 - /// Current number of times this code has been used.
944 955 pub use_count: i32,
945 - /// When this code expires (NULL = never).
946 956 pub expires_at: Option<DateTime<Utc>>,
947 - /// Restrict to a specific item (NULL = any item by this seller).
948 - pub item_id: Option<ItemId>,
949 - /// Restrict to a specific project (NULL = any project by this seller).
950 - pub project_id: Option<ProjectId>,
951 - /// When this code was created.
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 ──
@@ -0,0 +1,216 @@
1 + //! Unified promo code management: creation, validation, usage tracking, and deletion.
2 + //!
3 + //! Replaces the old `discount_codes` and `download_codes` modules. Supports three
4 + //! code purposes: discount, free_access, and free_trial.
5 +
6 + use sqlx::PgPool;
7 +
8 + use super::enums::DiscountType;
9 + use super::models::*;
10 + use super::{ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId};
11 + use crate::error::Result;
12 +
13 + /// Create a new promo code for a creator.
14 + #[allow(clippy::too_many_arguments)]
15 + pub async fn create_promo_code(
16 + pool: &PgPool,
17 + creator_id: UserId,
18 + code: &str,
19 + code_purpose: super::CodePurpose,
20 + discount_type: Option<DiscountType>,
21 + discount_value: Option<i32>,
22 + min_price_cents: i32,
23 + trial_days: Option<i32>,
24 + max_uses: Option<i32>,
25 + expires_at: Option<chrono::DateTime<chrono::Utc>>,
26 + item_id: Option<ItemId>,
27 + project_id: Option<ProjectId>,
28 + tier_id: Option<SubscriptionTierId>,
29 + ) -> Result<DbPromoCode> {
30 + let promo_code = sqlx::query_as::<_, DbPromoCode>(
31 + r#"
32 + INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value,
33 + min_price_cents, trial_days, max_uses, expires_at, item_id, project_id, tier_id)
34 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
35 + RETURNING *
36 + "#,
37 + )
38 + .bind(creator_id)
39 + .bind(code)
40 + .bind(code_purpose)
41 + .bind(discount_type)
42 + .bind(discount_value)
43 + .bind(min_price_cents)
44 + .bind(trial_days)
45 + .bind(max_uses)
46 + .bind(expires_at)
47 + .bind(item_id)
48 + .bind(project_id)
49 + .bind(tier_id)
50 + .fetch_one(pool)
51 + .await?;
52 +
53 + Ok(promo_code)
54 + }
55 +
56 + /// Fetch a promo code by primary key.
57 + pub async fn get_promo_code_by_id(pool: &PgPool, id: PromoCodeId) -> Result<Option<DbPromoCode>> {
58 + let code = sqlx::query_as::<_, DbPromoCode>(
59 + "SELECT * FROM promo_codes WHERE id = $1",
60 + )
61 + .bind(id)
62 + .fetch_optional(pool)
63 + .await?;
64 +
65 + Ok(code)
66 + }
67 +
68 + /// Look up a promo code by creator ID and code string (case-insensitive).
69 + /// Used at checkout to validate discount codes.
70 + pub async fn get_promo_code_by_creator_and_code(
71 + pool: &PgPool,
72 + creator_id: UserId,
73 + code: &str,
74 + ) -> Result<Option<DbPromoCode>> {
75 + let promo_code = sqlx::query_as::<_, DbPromoCode>(
76 + "SELECT * FROM promo_codes WHERE creator_id = $1 AND upper(code) = upper($2)",
77 + )
78 + .bind(creator_id)
79 + .bind(code)
80 + .fetch_optional(pool)
81 + .await?;
82 +
83 + Ok(promo_code)
84 + }
85 +
86 + /// Look up a promo code by code string (case-insensitive, cross-creator).
87 + /// Used for free_access code claims where the buyer doesn't know the creator.
88 + pub async fn get_promo_code_by_code(
89 + pool: &PgPool,
90 + code: &str,
91 + ) -> Result<Option<DbPromoCode>> {
92 + let promo_code = sqlx::query_as::<_, DbPromoCode>(
93 + "SELECT * FROM promo_codes WHERE upper(code) = upper($1)",
94 + )
95 + .bind(code)
96 + .fetch_optional(pool)
97 + .await?;
98 +
99 + Ok(promo_code)
100 + }
101 +
102 + /// SQL fragment for promo code listing queries: selects all promo_codes columns
103 + /// plus LEFT JOINed item and project titles.
104 + const PROMO_CODE_WITH_NAMES_SELECT: &str = r#"
105 + SELECT pc.*, i.title AS item_title, p.title AS project_title
106 + FROM promo_codes pc
107 + LEFT JOIN items i ON pc.item_id = i.id
108 + LEFT JOIN projects p ON pc.project_id = p.id
109 + "#;
110 +
111 + /// List all promo codes for a creator, newest first. Capped at 500.
112 + pub async fn get_promo_codes_by_creator(pool: &PgPool, creator_id: UserId) -> Result<Vec<DbPromoCodeWithNames>> {
113 + let query = format!("{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.creator_id = $1 ORDER BY pc.created_at DESC LIMIT 500");
114 + let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query)
115 + .bind(creator_id)
116 + .fetch_all(pool)
117 + .await?;
118 +
119 + Ok(codes)
120 + }
121 +
122 + /// List all promo codes scoped to a project, newest first. Capped at 500.
123 + pub async fn get_promo_codes_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbPromoCodeWithNames>> {
124 + let query = format!("{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.project_id = $1 ORDER BY pc.created_at DESC LIMIT 500");
125 + let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query)
126 + .bind(project_id)
127 + .fetch_all(pool)
128 + .await?;
129 +
130 + Ok(codes)
131 + }
132 +
133 + /// List all promo codes scoped to an item, newest first. Capped at 500.
134 + pub async fn get_promo_codes_by_item(pool: &PgPool, item_id: ItemId) -> Result<Vec<DbPromoCodeWithNames>> {
135 + let query = format!("{PROMO_CODE_WITH_NAMES_SELECT} WHERE pc.item_id = $1 ORDER BY pc.created_at DESC LIMIT 500");
136 + let codes = sqlx::query_as::<_, DbPromoCodeWithNames>(&query)
137 + .bind(item_id)
138 + .fetch_all(pool)
139 + .await?;
140 +
141 + Ok(codes)
142 + }
143 +
144 + /// Atomically increment use_count, respecting the max_uses limit.
145 + ///
146 + /// Returns `true` if the increment succeeded, `false` if the code has already
147 + /// reached its usage limit. The `WHERE` clause enforces the limit at the
148 + /// database level, preventing TOCTOU races.
149 + ///
150 + /// Accepts any sqlx executor (`&PgPool`, `&mut Transaction`, etc.) so callers
151 + /// can include this in a larger transaction when needed.
152 + pub async fn try_increment_use_count<'e>(
153 + executor: impl sqlx::PgExecutor<'e>,
154 + id: PromoCodeId,
155 + ) -> Result<bool> {
156 + let result = sqlx::query(
157 + "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)",
158 + )
159 + .bind(id)
160 + .execute(executor)
161 + .await?;
162 +
163 + Ok(result.rows_affected() > 0)
164 + }
165 +
166 + /// Delete a promo code permanently.
167 + pub async fn delete_promo_code(pool: &PgPool, id: PromoCodeId) -> Result<()> {
168 + sqlx::query("DELETE FROM promo_codes WHERE id = $1")
169 + .bind(id)
170 + .execute(pool)
171 + .await?;
172 +
173 + Ok(())
174 + }
175 +
176 + /// Apply a discount to a price, returning the discounted price in cents (minimum 0).
177 + pub fn apply_discount(price_cents: i32, discount_type: DiscountType, discount_value: i32) -> i32 {
178 + match discount_type {
179 + DiscountType::Percentage => {
180 + let discount = (price_cents as i64 * discount_value as i64) / 100;
181 + (price_cents - discount as i32).max(0)
182 + }
183 + DiscountType::Fixed => (price_cents - discount_value).max(0),
184 + }
185 + }
186 +
187 + #[cfg(test)]
188 + mod tests {
189 + use super::*;
190 +
191 + #[test]
192 + fn percentage_discount_50() {
193 + assert_eq!(apply_discount(1000, DiscountType::Percentage, 50), 500);
194 + }
195 +
196 + #[test]
197 + fn percentage_discount_100() {
198 + assert_eq!(apply_discount(1000, DiscountType::Percentage, 100), 0);
199 + }
200 +
201 + #[test]
202 + fn percentage_discount_10() {
203 + // 999 * 10 / 100 = 99 (integer), 999 - 99 = 900
204 + assert_eq!(apply_discount(999, DiscountType::Percentage, 10), 900);
205 + }
206 +
207 + #[test]
208 + fn fixed_discount() {
209 + assert_eq!(apply_discount(1000, DiscountType::Fixed, 300), 700);
210 + }
211 +
212 + #[test]
213 + fn fixed_discount_exceeds_price() {
214 + assert_eq!(apply_discount(100, DiscountType::Fixed, 500), 0);
215 + }
216 + }
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
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 @@ pub async fn claim_free_item<'e>(
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 @@ pub async fn claim_free_with_discount_code(
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
@@ -16,7 +16,7 @@ use stripe::{
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 @@ pub struct CheckoutParams<'a> {
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 @@ pub struct SubscriptionCheckoutParams<'a> {
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 @@ impl StripeClient {
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 @@ impl StripeClient {
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 @@ pub struct CheckoutMetadata {
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 @@ impl CheckoutMetadata {
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 @@ pub struct SubscriptionCheckoutMetadata {
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 @@ impl SubscriptionCheckoutMetadata {
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 }
@@ -1,223 +0,0 @@
1 - //! Discount code management API for creators.
2 -
3 - use axum::{
4 - extract::{Path, State},
5 - http::{header::HeaderMap, StatusCode},
6 - response::{IntoResponse, Response},
7 - Form, Json,
8 - };
9 - use serde::{Deserialize, Serialize};
10 -
11 - use crate::{
12 - auth::AuthUser,
13 - db::{self, DiscountCodeId, DiscountType, ItemId, ProjectId},
14 - error::{AppError, Result},
15 - helpers::{hx_toast, is_htmx_request},
16 - templates::{DiscountCodeRow, UserDiscountCodesTemplate},
17 - types::ListResponse,
18 - AppState,
19 - };
20 -
21 - /// JSON response representing a discount code.
22 - #[derive(Debug, Serialize)]
23 - struct DiscountCodeResponse {
24 - id: DiscountCodeId,
25 - code: String,
26 - discount_type: DiscountType,
27 - discount_value: i32,
28 - }
29 -
30 - // =============================================================================
31 - // Creator management (auth required)
32 - // =============================================================================
33 -
34 - /// Form input for creating a discount code.
35 - #[derive(Debug, Deserialize)]
36 - pub struct CreateDiscountCodeForm {
37 - pub code: String,
38 - pub discount_type: DiscountType,
39 - pub discount_value: i32,
40 - pub max_uses: Option<i32>,
41 - pub item_id: Option<String>,
42 - pub project_id: Option<String>,
43 - }
44 -
45 - /// Create a new discount code (creator dashboard).
46 - #[tracing::instrument(skip_all, name = "discount_codes::create_discount_code")]
47 - pub(super) async fn create_discount_code(
48 - State(state): State<AppState>,
49 - headers: HeaderMap,
50 - AuthUser(user): AuthUser,
51 - Form(req): Form<CreateDiscountCodeForm>,
52 - ) -> Result<Response> {
53 - // Validate code
54 - let code = req.code.trim().to_uppercase();
55 - if code.is_empty() || code.len() > 50 {
56 - return Err(AppError::BadRequest("Code must be 1-50 characters".to_string()));
57 - }
58 -
59 - // Validate discount value
60 - match req.discount_type {
61 - DiscountType::Percentage => {
62 - if req.discount_value < 1 || req.discount_value > 100 {
63 - return Err(AppError::BadRequest("Percentage must be 1-100".to_string()));
64 - }
65 - }
66 - DiscountType::Fixed => {
67 - if req.discount_value < 1 {
68 - return Err(AppError::BadRequest("Fixed discount must be at least 1 cent".to_string()));
69 - }
70 - }
71 - }
72 -
73 - if let Some(max) = req.max_uses && max < 1 {
74 - return Err(AppError::BadRequest("Max uses must be at least 1".to_string()));
75 - }
76 -
77 - // Parse optional item_id
78 - let item_id = if let Some(ref id_str) = req.item_id {
79 - let id_str = id_str.trim();
80 - if id_str.is_empty() {
81 - None
82 - } else {
83 - let item_id: ItemId = id_str.parse()
84 - .map_err(|_| AppError::BadRequest("Invalid item ID".to_string()))?;
85 - // Verify the item belongs to this seller
86 - let item = db::items::get_item_by_id(&state.db, item_id)
87 - .await?
88 - .ok_or(AppError::NotFound)?;
89 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
90 - .await?
91 - .ok_or(AppError::NotFound)?;
92 - if project.user_id != user.id {
93 - return Err(AppError::Forbidden);
94 - }
95 - Some(item_id)
96 - }
97 - } else {
98 - None
99 - };
100 -
101 - // Parse optional project_id
102 - let project_id = if let Some(ref id_str) = req.project_id {
103 - let id_str = id_str.trim();
104 - if id_str.is_empty() {
105 - None
106 - } else {
107 - let pid: ProjectId = id_str.parse()
108 - .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
109 - // Verify the project belongs to this user
110 - let project = db::projects::get_project_by_id(&state.db, pid)
111 - .await?
112 - .ok_or(AppError::NotFound)?;
113 - if project.user_id != user.id {
114 - return Err(AppError::Forbidden);
115 - }
116 - Some(pid)
117 - }
118 - } else {
119 - None
120 - };
121 -
122 - let discount_code = db::discount_codes::create_discount_code(
123 - &state.db,
124 - user.id,
125 - &code,
126 - req.discount_type,
127 - req.discount_value,
128 - 0, // min_price_cents defaults to 0
129 - req.max_uses,
130 - None, // no expiry for now
131 - item_id,
132 - project_id,
133 - )
134 - .await?;
135 -
136 - if is_htmx_request(&headers) {
137 - // Return project-scoped codes if created from project context, otherwise seller-global
138 - let codes = if let Some(pid) = project_id {
139 - db::discount_codes::get_discount_codes_by_project(&state.db, pid).await?
140 - } else {
141 - db::discount_codes::get_discount_codes_by_seller(&state.db, user.id).await?
142 - };
143 - return Ok((
144 - [("HX-Trigger", hx_toast("Discount code created", "success"))],
145 - UserDiscountCodesTemplate {
146 - discount_codes: codes.into_iter().map(DiscountCodeRow::from).collect(),
147 - },
148 - )
149 - .into_response());
150 - }
151 -
152 - Ok(Json(DiscountCodeResponse {
153 - id: discount_code.id,
154 - code: discount_code.code,
155 - discount_type: discount_code.discount_type,
156 - discount_value: discount_code.discount_value,
157 - })
158 - .into_response())
159 - }
160 -
161 - /// List all discount codes for the authenticated seller.
162 - #[tracing::instrument(skip_all, name = "discount_codes::list_discount_codes")]
163 - pub(super) async fn list_discount_codes(
164 - State(state): State<AppState>,
165 - headers: HeaderMap,
166 - AuthUser(user): AuthUser,
167 - ) -> Result<Response> {
168 - let codes = db::discount_codes::get_discount_codes_by_seller(&state.db, user.id).await?;
169 -
170 - if is_htmx_request(&headers) {
171 - return Ok(UserDiscountCodesTemplate {
172 - discount_codes: codes.into_iter().map(DiscountCodeRow::from).collect(),
173 - }
174 - .into_response());
175 - }
176 -
177 - let data: Vec<DiscountCodeResponse> = codes.into_iter().map(|c| DiscountCodeResponse {
178 - id: c.id,
179 - code: c.code,
180 - discount_type: c.discount_type,
181 - discount_value: c.discount_value,
182 - }).collect();
183 -
184 - Ok(Json(ListResponse { data }).into_response())
185 - }
186 -
187 - /// Delete a discount code.
188 - #[tracing::instrument(skip_all, name = "discount_codes::delete_discount_code")]
189 - pub(super) async fn delete_discount_code(
190 - State(state): State<AppState>,
191 - headers: HeaderMap,
192 - AuthUser(user): AuthUser,
193 - Path(code_id): Path<DiscountCodeId>,
194 - ) -> Result<Response> {
195 - let discount_code = db::discount_codes::get_discount_code_by_id(&state.db, code_id)
196 - .await?
197 - .ok_or(AppError::NotFound)?;
198 -
199 - if discount_code.seller_id != user.id {
200 - return Err(AppError::Forbidden);
201 - }
202 -
203 - let deleted_project_id = discount_code.project_id;
204 - db::discount_codes::delete_discount_code(&state.db, code_id).await?;
205 -
206 - if is_htmx_request(&headers) {
207 - // Return project-scoped codes if deleted from project context, otherwise seller-global
208 - let codes = if let Some(pid) = deleted_project_id {
209 - db::discount_codes::get_discount_codes_by_project(&state.db, pid).await?
210 - } else {
211 - db::discount_codes::get_discount_codes_by_seller(&state.db, user.id).await?
212 - };
213 - return Ok((
214 - [("HX-Trigger", hx_toast("Discount code deleted", "success"))],
215 - UserDiscountCodesTemplate {
216 - discount_codes: codes.into_iter().map(DiscountCodeRow::from).collect(),
217 - },
218 - )
219 - .into_response());
220 - }
221 -
222 - Ok(StatusCode::NO_CONTENT.into_response())
223 - }
@@ -1,264 +0,0 @@
1 - //! Download code management and public claim API.
2 -
3 - use axum::{
4 - extract::{Path, State},
5 - http::{header::HeaderMap, StatusCode},
6 - response::{IntoResponse, Response},
7 - Form, Json,
8 - };
9 - use chrono::{DateTime, Utc};
10 - use serde::{Deserialize, Serialize};
11 -
12 - use crate::{
13 - auth::AuthUser,
14 - db::{self, DownloadCodeId, ItemId, KeyCode},
15 - error::{AppError, Result},
16 - helpers::{self, hx_toast, is_htmx_request},
17 - templates::{DownloadCodeRow, ItemDownloadCodesTemplate},
18 - types::ListResponse,
19 - AppState,
20 - };
21 -
22 - use super::verify_item_ownership;
23 -
24 - /// JSON response representing a download code.
25 - #[derive(Debug, Serialize)]
26 - struct DownloadCodeResponse {
27 - id: DownloadCodeId,
28 - code: KeyCode,
29 - max_uses: Option<i32>,
30 - created_at: DateTime<Utc>,
31 - }
32 -
33 - /// JSON response for a download code claim.
34 - #[derive(Debug, Serialize)]
35 - struct ClaimDownloadCodeResponse {
36 - success: bool,
37 - already_owned: bool,
38 - item_id: ItemId,
39 - }
40 -
41 - // =============================================================================
42 - // Creator management (auth required)
43 - // =============================================================================
44 -
45 - /// Form input for generating a download code.
46 - #[derive(Debug, Deserialize)]
47 - pub struct GenerateDownloadCodeForm {
48 - pub max_uses: Option<i32>,
49 - }
50 -
51 - /// Generate a new download code for an item (creator dashboard).
52 - #[tracing::instrument(skip_all, name = "download_codes::generate_download_code")]
53 - pub(super) async fn generate_download_code(
54 - State(state): State<AppState>,
55 - headers: HeaderMap,
56 - AuthUser(user): AuthUser,
57 - Path(item_id): Path<ItemId>,
58 - Form(req): Form<GenerateDownloadCodeForm>,
59 - ) -> Result<Response> {
60 - verify_item_ownership(&state, item_id, user.id).await?;
61 -
62 - if let Some(max) = req.max_uses && max < 1 {
63 - return Err(AppError::BadRequest("Max uses must be at least 1".to_string()));
64 - }
65 -
66 - let code = helpers::generate_key_code();
67 -
68 - let download_code = db::download_codes::create_download_code(
69 - &state.db,
70 - item_id,
71 - user.id,
72 - &code,
73 - req.max_uses,
74 - None,
75 - )
76 - .await?;
77 -
78 - if is_htmx_request(&headers) {
79 - let codes = db::download_codes::get_download_codes_by_item(&state.db, item_id).await?;
80 - return Ok(ItemDownloadCodesTemplate {
81 - download_codes: codes.into_iter().map(DownloadCodeRow::from).collect(),
82 - }
83 - .into_response());
84 - }
85 -
86 - Ok(Json(DownloadCodeResponse {
87 - id: download_code.id,
88 - code: download_code.code,
89 - max_uses: download_code.max_uses,
90 - created_at: download_code.created_at,
91 - })
92 - .into_response())
93 - }
94 -
95 - /// List all download codes for an item (creator dashboard).
96 - #[tracing::instrument(skip_all, name = "download_codes::list_download_codes")]
97 - pub(super) async fn list_download_codes(
98 - State(state): State<AppState>,
99 - headers: HeaderMap,
100 - AuthUser(user): AuthUser,
101 - Path(item_id): Path<ItemId>,
102 - ) -> Result<Response> {
103 - verify_item_ownership(&state, item_id, user.id).await?;
104 -
105 - let codes = db::download_codes::get_download_codes_by_item(&state.db, item_id).await?;
106 -
107 - if is_htmx_request(&headers) {
108 - return Ok(ItemDownloadCodesTemplate {
109 - download_codes: codes.into_iter().map(DownloadCodeRow::from).collect(),
110 - }
111 - .into_response());
112 - }
113 -
114 - let data: Vec<DownloadCodeResponse> = codes.into_iter().map(|c| DownloadCodeResponse {
115 - id: c.id,
116 - code: c.code,
117 - max_uses: c.max_uses,
118 - created_at: c.created_at,
119 - }).collect();
120 -
121 - Ok(Json(ListResponse { data }).into_response())
122 - }
123 -
124 - /// Delete a download code (creator dashboard).
125 - #[tracing::instrument(skip_all, name = "download_codes::delete_download_code")]
126 - pub(super) async fn delete_download_code(
127 - State(state): State<AppState>,
128 - headers: HeaderMap,
129 - AuthUser(user): AuthUser,
130 - Path(code_id): Path<DownloadCodeId>,
131 - ) -> Result<Response> {
132 - let download_code = db::download_codes::get_download_code_by_id(&state.db, code_id)
133 - .await?
134 - .ok_or(AppError::NotFound)?;
135 -
136 - verify_item_ownership(&state, download_code.item_id, user.id).await?;
137 -
138 - db::download_codes::delete_download_code(&state.db, code_id).await?;
139 -
140 - if is_htmx_request(&headers) {
141 - let codes = db::download_codes::get_download_codes_by_item(&state.db, download_code.item_id).await?;
142 - return Ok((
143 - [("HX-Trigger", hx_toast("Download code deleted", "success"))],
144 - ItemDownloadCodesTemplate {
145 - download_codes: codes.into_iter().map(DownloadCodeRow::from).collect(),
146 - },
147 - )
148 - .into_response());
149 - }
150 -
151 - Ok(StatusCode::NO_CONTENT.into_response())
152 - }
153 -
154 - // =============================================================================
155 - // Public claim (auth required, rate-limited)
156 - // =============================================================================
157 -
158 - /// Form/JSON input for claiming a download code.
159 - #[derive(Debug, Deserialize)]
160 - pub struct ClaimDownloadCodeForm {
161 - pub code: KeyCode,
162 - }
163 -
164 - /// Claim a download code: validates the code and grants free access to the item.
165 - #[tracing::instrument(skip_all, name = "api::claim_download_code")]
166 - pub(super) async fn claim_download_code(
167 - State(state): State<AppState>,
168 - headers: HeaderMap,
169 - AuthUser(user): AuthUser,
170 - Form(req): Form<ClaimDownloadCodeForm>,
171 - ) -> Result<Response> {
172 - let is_htmx = is_htmx_request(&headers);
173 -
174 - // code is auto-validated by KeyCode deserialization
175 -
176 - // Look up the code
177 - let download_code = db::download_codes::get_download_code_by_code(&state.db, &req.code)
178 - .await?
179 - .ok_or_else(|| AppError::BadRequest("Invalid download code".to_string()))?;
180 -
181 - // Check expiration
182 - if let Some(expires_at) = download_code.expires_at && expires_at < chrono::Utc::now() {
183 - return Err(AppError::BadRequest("This download code has expired".to_string()));
184 - }
185 -
186 - // Check usage limit
187 - if let Some(max_uses) = download_code.max_uses && download_code.use_count >= max_uses {
188 - return Err(AppError::BadRequest("This download code has reached its usage limit".to_string()));
189 - }
190 -
191 - // Get the item and its seller info for the transaction record
192 - let item = db::items::get_item_by_id(&state.db, download_code.item_id)
193 - .await?
194 - .ok_or(AppError::NotFound)?;
195 -
196 - if !item.is_public {
197 - return Err(AppError::NotFound);
198 - }
199 -
200 - let project = db::projects::get_project_by_id(&state.db, item.project_id)
201 - .await?
202 - .ok_or(AppError::NotFound)?;
203 -
204 - let seller = db::users::get_user_by_id(&state.db, project.user_id)
205 - .await?
206 - .ok_or(AppError::NotFound)?;
207 -
208 - // Wrap download code increment + claim in a single transaction
209 - // so use_count doesn't drift if the claim step fails.
210 - let (code_accepted, claimed) = db::transactions::claim_free_with_download_code(
211 - &state.db,
212 - download_code.id,
213 - &db::transactions::ClaimParams {
214 - buyer_id: user.id,
215 - item_id: download_code.item_id,
216 - seller_id: project.user_id,
217 - item_title: &item.title,
218 - seller_username: &seller.username,
219 - share_contact: false,
220 - },
221 - )
222 - .await?;
223 -
224 - if !code_accepted {
225 - return Err(AppError::BadRequest("This download code has reached its usage limit".to_string()));
226 - }
227 -
228 - if claimed {
229 - // Sales count already incremented inside claim_free_with_download_code transaction
230 -
231 - // Generate license key if item has keys enabled
232 - if item.enable_license_keys {
233 - let key_code = helpers::generate_key_code();
234 - let _ = db::license_keys::create_license_key(
235 - &state.db,
236 - download_code.item_id,
237 - user.id,
238 - None,
239 - &key_code,
240 - item.default_max_activations,
241 - )
242 - .await;
243 - }
244 - }
245 -
246 - if is_htmx {
247 - return Ok((
248 - [("HX-Trigger", hx_toast("Item added to your library", "success"))],
249 - Json(ClaimDownloadCodeResponse {
250 - success: true,
251 - already_owned: !claimed,
252 - item_id: download_code.item_id,
253 - }),
254 - )
255 - .into_response());
256 - }
257 -
258 - Ok(Json(ClaimDownloadCodeResponse {
259 - success: true,
260 - already_owned: !claimed,
261 - item_id: download_code.item_id,
262 - })
263 - .into_response())
264 - }
@@ -38,19 +38,23 @@ pub(super) async fn export_projects(
38 38 let all_item_ids: Vec<db::ItemId> = all_items.iter().map(|i| i.id).collect();
39 39 let tags_map = db::tags::get_tags_for_items(&state.db, &all_item_ids).await?;
40 40
41 - // Discount codes are seller-scoped (not per-item), fetch once
42 - let discount_codes = db::discount_codes::get_discount_codes_by_seller(&state.db, user.id).await?;
43 - let discount_codes_data: Vec<serde_json::Value> = discount_codes.iter().map(|dc| {
41 + // Promo codes are creator-scoped, fetch once
42 + let promo_codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?;
43 + let promo_codes_data: Vec<serde_json::Value> = promo_codes.iter().map(|pc| {
44 44 serde_json::json!({
45 - "code": dc.code,
46 - "discount_type": dc.discount_type,
47 - "discount_value": dc.discount_value,
48 - "min_price_cents": dc.min_price_cents,
49 - "max_uses": dc.max_uses,
50 - "use_count": dc.use_count,
51 - "expires_at": dc.expires_at,
52 - "item_id": dc.item_id,
53 - "created_at": dc.created_at,
45 + "code": pc.code,
46 + "code_purpose": pc.code_purpose.to_string(),
47 + "discount_type": pc.discount_type.map(|dt| dt.to_string()),
48 + "discount_value": pc.discount_value,
49 + "min_price_cents": pc.min_price_cents,
50 + "trial_days": pc.trial_days,
51 + "max_uses": pc.max_uses,
52 + "use_count": pc.use_count,
53 + "expires_at": pc.expires_at,
54 + "item_id": pc.item_id,
55 + "project_id": pc.project_id,
56 + "tier_id": pc.tier_id,
57 + "created_at": pc.created_at,
54 58 })
55 59 }).collect();
56 60
@@ -126,15 +130,16 @@ pub(super) async fn export_projects(
126 130 })
127 131 }).collect();
128 132
129 - // Download codes
130 - let download_codes = db::download_codes::get_download_codes_by_item(&state.db, item.id).await?;
131 - let download_codes_data: Vec<serde_json::Value> = download_codes.iter().map(|dc| {
133 + // Item-scoped promo codes
134 + let item_promo_codes = db::promo_codes::get_promo_codes_by_item(&state.db, item.id).await?;
135 + let item_promo_codes_data: Vec<serde_json::Value> = item_promo_codes.iter().map(|pc| {
132 136 serde_json::json!({
133 - "code": dc.code,
134 - "max_uses": dc.max_uses,
135 - "use_count": dc.use_count,
136 - "expires_at": dc.expires_at,
137 - "created_at": dc.created_at,
137 + "code": pc.code,
138 + "code_purpose": pc.code_purpose.to_string(),
139 + "max_uses": pc.max_uses,
140 + "use_count": pc.use_count,
141 + "expires_at": pc.expires_at,
142 + "created_at": pc.created_at,
138 143 })
139 144 }).collect();
140 145
@@ -150,7 +155,7 @@ pub(super) async fn export_projects(
150 155 "chapters": chapters_data,
151 156 "versions": versions_data,
152 157 "license_keys": license_keys_data,
153 - "download_codes": download_codes_data,
158 + "promo_codes": item_promo_codes_data,
154 159 });
155 160
156 161 // Merge content-type fields into item JSON
@@ -193,7 +198,7 @@ pub(super) async fn export_projects(
193 198 let json_content = serde_json::to_string_pretty(&serde_json::json!({
194 199 "exported_at": chrono::Utc::now().to_rfc3339(),
195 200 "projects": export_data,
196 - "discount_codes": discount_codes_data,
201 + "promo_codes": promo_codes_data,
197 202 })).unwrap_or_else(|_| "{}".to_string());
198 203
199 204 if is_htmx {
@@ -19,8 +19,7 @@
19 19 mod blog;
20 20 mod categories;
21 21 mod content_insertions;
22 - mod discount_codes;
23 - mod download_codes;
22 + mod promo_codes;
24 23 mod exports;
25 24 mod follows;
26 25 mod items;
@@ -224,16 +223,12 @@ pub fn api_routes() -> Router<AppState> {
224 223 .route("/api/items/{id}/keys", post(license_keys::generate_key))
225 224 .route("/api/items/{id}/keys", get(license_keys::list_keys))
226 225 .route("/api/keys/{id}/revoke", post(license_keys::revoke_key))
227 - // Download code management (creator)
228 - .route("/api/items/{id}/download-codes", post(download_codes::generate_download_code))
229 - .route("/api/items/{id}/download-codes", get(download_codes::list_download_codes))
230 - .route("/api/download-codes/{id}", delete(download_codes::delete_download_code))
231 - // Download code claim (buyer)
232 - .route("/api/download-codes/claim", post(download_codes::claim_download_code))
233 - // Discount code management (creator)
234 - .route("/api/discount-codes", post(discount_codes::create_discount_code))
235 - .route("/api/discount-codes", get(discount_codes::list_discount_codes))
236 - .route("/api/discount-codes/{id}", delete(discount_codes::delete_discount_code))
226 + // Promo code management (creator)
227 + .route("/api/promo-codes", post(promo_codes::create_promo_code))
228 + .route("/api/promo-codes", get(promo_codes::list_promo_codes))
229 + .route("/api/promo-codes/{id}", delete(promo_codes::delete_promo_code))
230 + // Promo code claim (buyer — free_access codes)
231 + .route("/api/promo-codes/claim", post(promo_codes::claim_promo_code))
237 232 // Subscription tier management (creator)
238 233 .route("/api/projects/{id}/tiers", post(subscriptions::create_tier))
239 234 .route("/api/tiers/{id}", put(subscriptions::update_tier))
@@ -0,0 +1,423 @@
1 + //! Unified promo code management API for creators and public claim endpoint.
2 +
3 + use axum::{
4 + extract::{Path, State},
5 + http::{header::HeaderMap, StatusCode},
6 + response::{IntoResponse, Response},
7 + Form, Json,
8 + };
9 + use serde::{Deserialize, Serialize};
10 +
11 + use crate::{
12 + auth::AuthUser,
13 + db::{self, CodePurpose, DiscountType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId},
14 + error::{AppError, Result},
15 + helpers::{self, hx_toast, is_htmx_request},
16 + templates::{PromoCodeRow, PromoCodesListTemplate},
17 + types::ListResponse,
18 + AppState,
19 + };
20 +
21 + use super::verify_item_ownership;
22 +
23 + /// JSON response representing a promo code.
24 + #[derive(Debug, Serialize)]
25 + struct PromoCodeResponse {
26 + id: PromoCodeId,
27 + code: String,
28 + code_purpose: CodePurpose,
29 + discount_type: Option<DiscountType>,
30 + discount_value: Option<i32>,
31 + trial_days: Option<i32>,
32 + max_uses: Option<i32>,
33 + use_count: i32,
34 + }
35 +
36 + /// JSON response for a free_access code claim.
37 + #[derive(Debug, Serialize)]
38 + struct ClaimPromoCodeResponse {
39 + success: bool,
40 + already_owned: bool,
41 + item_id: ItemId,
42 + }
43 +
44 + // =============================================================================
45 + // Creator management (auth required)
46 + // =============================================================================
47 +
48 + /// Form input for creating a promo code.
49 + #[derive(Debug, Deserialize)]
50 + pub struct CreatePromoCodeForm {
51 + pub code: Option<String>,
52 + pub code_purpose: CodePurpose,
53 + pub discount_type: Option<DiscountType>,
54 + pub discount_value: Option<i32>,
55 + pub trial_days: Option<i32>,
56 + pub max_uses: Option<i32>,
57 + /// Optional expiry date (HTML date input: YYYY-MM-DD).
58 + pub expires_at: Option<String>,
59 + pub item_id: Option<String>,
60 + pub project_id: Option<String>,
61 + pub tier_id: Option<String>,
62 + }
63 +
64 + /// Create a new promo code (creator dashboard).
65 + #[tracing::instrument(skip_all, name = "promo_codes::create_promo_code")]
66 + pub(super) async fn create_promo_code(
67 + State(state): State<AppState>,
68 + headers: HeaderMap,
69 + AuthUser(user): AuthUser,
70 + Form(req): Form<CreatePromoCodeForm>,
71 + ) -> Result<Response> {
72 + // Generate or validate code
73 + let code = match req.code_purpose {
74 + CodePurpose::FreeAccess => {
75 + // Auto-generate word-based code for free_access (keep lowercase)
76 + if let Some(ref c) = req.code {
77 + let c = c.trim().to_string();
78 + if c.is_empty() {
79 + helpers::generate_key_code().into_inner()
80 + } else {
81 + c
82 + }
83 + } else {
84 + helpers::generate_key_code().into_inner()
85 + }
86 + }
87 + _ => {
88 + let code = req.code.as_deref().unwrap_or("").trim().to_uppercase();
89 + if code.is_empty() || code.len() > 50 {
90 + return Err(AppError::BadRequest("Code must be 1-50 characters".to_string()));
91 + }
92 + code
93 + }
94 + };
95 +
96 + // Validate purpose-specific fields
97 + match req.code_purpose {
98 + CodePurpose::Discount => {
99 + let dt = req.discount_type
100 + .ok_or_else(|| AppError::BadRequest("Discount type is required".to_string()))?;
101 + let dv = req.discount_value
102 + .ok_or_else(|| AppError::BadRequest("Discount value is required".to_string()))?;
103 + match dt {
104 + DiscountType::Percentage => {
105 + if dv < 1 || dv > 100 {
106 + return Err(AppError::BadRequest("Percentage must be 1-100".to_string()));
107 + }
108 + }
109 + DiscountType::Fixed => {
110 + if dv < 1 {
111 + return Err(AppError::BadRequest("Fixed discount must be at least 1 cent".to_string()));
112 + }
113 + }
114 + }
115 + }
116 + CodePurpose::FreeTrial => {
117 + let days = req.trial_days
118 + .ok_or_else(|| AppError::BadRequest("Trial days is required".to_string()))?;
119 + if days < 1 {
120 + return Err(AppError::BadRequest("Trial days must be at least 1".to_string()));
121 + }
122 + }
123 + CodePurpose::FreeAccess => {
124 + // No extra validation needed
125 + }
126 + }
127 +
128 + if let Some(max) = req.max_uses && max < 1 {
129 + return Err(AppError::BadRequest("Max uses must be at least 1".to_string()));
130 + }
131 +
132 + // Parse optional item_id
133 + let item_id = if let Some(ref id_str) = req.item_id {
134 + let id_str = id_str.trim();
135 + if id_str.is_empty() {
136 + None
137 + } else {
138 + let item_id: ItemId = id_str.parse()
139 + .map_err(|_| AppError::BadRequest("Invalid item ID".to_string()))?;
140 + verify_item_ownership(&state, item_id, user.id).await?;
141 + Some(item_id)
142 + }
143 + } else {
144 + None
145 + };
146 +
147 + // Parse optional project_id
148 + let project_id = if let Some(ref id_str) = req.project_id {
149 + let id_str = id_str.trim();
150 + if id_str.is_empty() {
151 + None
152 + } else {
153 + let pid: ProjectId = id_str.parse()
154 + .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
155 + let project = db::projects::get_project_by_id(&state.db, pid)
156 + .await?
157 + .ok_or(AppError::NotFound)?;
158 + if project.user_id != user.id {
159 + return Err(AppError::Forbidden);
160 + }
161 + Some(pid)
162 + }
163 + } else {
164 + None
165 + };
166 +
167 + // Parse optional tier_id
168 + let tier_id = if let Some(ref id_str) = req.tier_id {
169 + let id_str = id_str.trim();
170 + if id_str.is_empty() {
171 + None
172 + } else {
173 + let tid: SubscriptionTierId = id_str.parse()
174 + .map_err(|_| AppError::BadRequest("Invalid tier ID".to_string()))?;
175 + Some(tid)
176 + }
177 + } else {
178 + None
179 + };
180 +
181 + // Parse optional expiry date (YYYY-MM-DD from HTML date input)
182 + let expires_at = if let Some(ref date_str) = req.expires_at {
183 + let date_str = date_str.trim();
184 + if date_str.is_empty() {
185 + None
186 + } else {
187 + let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
188 + .map_err(|_| AppError::BadRequest("Invalid expiry date".to_string()))?;
189 + // Expire at end of the given day (UTC)
190 + Some(date.and_hms_opt(23, 59, 59).unwrap().and_utc())
191 + }
192 + } else {
193 + None
194 + };
195 +
196 + let promo_code = db::promo_codes::create_promo_code(
197 + &state.db,
198 + user.id,
199 + &code,
200 + req.code_purpose,
201 + req.discount_type,
202 + req.discount_value,
203 + 0, // min_price_cents defaults to 0
204 + req.trial_days,
205 + req.max_uses,
206 + expires_at,
207 + item_id,
208 + project_id,
209 + tier_id,
210 + )
211 + .await?;
212 +
213 + if is_htmx_request(&headers) {
214 + // Return project-scoped codes if created from project context, otherwise creator-global
215 + let codes = if let Some(pid) = project_id {
216 + db::promo_codes::get_promo_codes_by_project(&state.db, pid).await?
217 + } else {
218 + db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?
219 + };
220 + return Ok((
221 + [("HX-Trigger", hx_toast("Promo code created", "success"))],
222 + PromoCodesListTemplate {
223 + promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
224 + },
225 + )
226 + .into_response());
227 + }
228 +
229 + Ok(Json(PromoCodeResponse {
230 + id: promo_code.id,
231 + code: promo_code.code,
232 + code_purpose: promo_code.code_purpose,
233 + discount_type: promo_code.discount_type,
234 + discount_value: promo_code.discount_value,
235 + trial_days: promo_code.trial_days,
236 + max_uses: promo_code.max_uses,
237 + use_count: promo_code.use_count,
238 + })
239 + .into_response())
240 + }
241 +
242 + /// List all promo codes for the authenticated creator.
243 + #[tracing::instrument(skip_all, name = "promo_codes::list_promo_codes")]
244 + pub(super) async fn list_promo_codes(
245 + State(state): State<AppState>,
246 + headers: HeaderMap,
247 + AuthUser(user): AuthUser,
248 + ) -> Result<Response> {
249 + let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?;
250 +
251 + if is_htmx_request(&headers) {
252 + return Ok(PromoCodesListTemplate {
253 + promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
254 + }
255 + .into_response());
256 + }
257 +
258 + let data: Vec<PromoCodeResponse> = codes.into_iter().map(|c| PromoCodeResponse {
259 + id: c.id,
260 + code: c.code,
261 + code_purpose: c.code_purpose,
262 + discount_type: c.discount_type,
263 + discount_value: c.discount_value,
264 + trial_days: c.trial_days,
265 + max_uses: c.max_uses,
266 + use_count: c.use_count,
267 + }).collect();
268 +
269 + Ok(Json(ListResponse { data }).into_response())
270 + }
271 +
272 + /// Delete a promo code.
273 + #[tracing::instrument(skip_all, name = "promo_codes::delete_promo_code")]
274 + pub(super) async fn delete_promo_code(
275 + State(state): State<AppState>,
276 + headers: HeaderMap,
277 + AuthUser(user): AuthUser,
278 + Path(code_id): Path<PromoCodeId>,
279 + ) -> Result<Response> {
280 + let promo_code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
281 + .await?
282 + .ok_or(AppError::NotFound)?;
283 +
284 + if promo_code.creator_id != user.id {
285 + return Err(AppError::Forbidden);
286 + }
287 +
288 + let deleted_project_id = promo_code.project_id;
289 + db::promo_codes::delete_promo_code(&state.db, code_id).await?;
290 +
291 + if is_htmx_request(&headers) {
292 + let codes = if let Some(pid) = deleted_project_id {
293 + db::promo_codes::get_promo_codes_by_project(&state.db, pid).await?
294 + } else {
295 + db::promo_codes::get_promo_codes_by_creator(&state.db, user.id).await?
296 + };
297 + return Ok((
298 + [("HX-Trigger", hx_toast("Promo code deleted", "success"))],
299 + PromoCodesListTemplate {
300 + promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
301 + },
302 + )
303 + .into_response());
304 + }
305 +
306 + Ok(StatusCode::NO_CONTENT.into_response())
307 + }
308 +
309 + // =============================================================================
310 + // Public claim (auth required, rate-limited)
311 + // =============================================================================
312 +
313 + /// Form/JSON input for claiming a free_access promo code.
314 + #[derive(Debug, Deserialize)]
315 + pub struct ClaimPromoCodeForm {
316 + pub code: db::KeyCode,
317 + }
318 +
319 + /// Claim a free_access promo code: validates the code and grants free access to the item.
320 + #[tracing::instrument(skip_all, name = "api::claim_promo_code")]
321 + pub(super) async fn claim_promo_code(
322 + State(state): State<AppState>,
323 + headers: HeaderMap,
324 + AuthUser(user): AuthUser,
325 + Form(req): Form<ClaimPromoCodeForm>,
326 + ) -> Result<Response> {
327 + let is_htmx = is_htmx_request(&headers);
328 +
329 + // Look up the code
330 + let promo_code = db::promo_codes::get_promo_code_by_code(&state.db, &req.code)
331 + .await?
332 + .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
333 +
334 + // Only free_access codes can be claimed this way
335 + if promo_code.code_purpose != CodePurpose::FreeAccess {
336 + return Err(AppError::BadRequest("Invalid promo code".to_string()));
337 + }
338 +
339 + // Must have an item scope
340 + let item_id = promo_code.item_id
341 + .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
342 +
343 + // Check expiration
344 + if let Some(expires_at) = promo_code.expires_at && expires_at < chrono::Utc::now() {
345 + return Err(AppError::BadRequest("This promo code has expired".to_string()));
346 + }
347 +
348 + // Check usage limit
349 + if let Some(max_uses) = promo_code.max_uses && promo_code.use_count >= max_uses {
350 + return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
351 + }
352 +
353 + // Get the item and its seller info for the transaction record
354 + let item = db::items::get_item_by_id(&state.db, item_id)
355 + .await?
356 + .ok_or(AppError::NotFound)?;
357 +
358 + if !item.is_public {
359 + return Err(AppError::NotFound);
360 + }
361 +
362 + let project = db::projects::get_project_by_id(&state.db, item.project_id)
363 + .await?
364 + .ok_or(AppError::NotFound)?;
365 +
366 + let seller = db::users::get_user_by_id(&state.db, project.user_id)
367 + .await?
368 + .ok_or(AppError::NotFound)?;
369 +
370 + // Wrap promo code increment + claim in a single transaction
371 + let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code(
372 + &state.db,
373 + promo_code.id,
374 + &db::transactions::ClaimParams {
375 + buyer_id: user.id,
376 + item_id,
377 + seller_id: project.user_id,
378 + item_title: &item.title,
379 + seller_username: &seller.username,
380 + share_contact: false,
381 + },
382 + )
383 + .await?;
384 +
385 + if !code_accepted {
386 + return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
387 + }
388 +
389 + if claimed {
390 + // Generate license key if item has keys enabled
391 + if item.enable_license_keys {
392 + let key_code = helpers::generate_key_code();
393 + let _ = db::license_keys::create_license_key(
394 + &state.db,
395 + item_id,
396 + user.id,
397 + None,
398 + &key_code,
399 + item.default_max_activations,
400 + )
401 + .await;
402 + }
403 + }
404 +
405 + if is_htmx {
406 + return Ok((
407 + [("HX-Trigger", hx_toast("Item added to your library", "success"))],
408 + Json(ClaimPromoCodeResponse {
409 + success: true,
410 + already_owned: !claimed,
411 + item_id,
412 + }),
413 + )
414 + .into_response());
415 + }
416 +
417 + Ok(Json(ClaimPromoCodeResponse {
418 + success: true,
419 + already_owned: !claimed,
420 + item_id,
421 + })
422 + .into_response())
423 + }
@@ -215,12 +215,12 @@ pub(super) async fn dashboard_item(
215 215 let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
216 216 let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, true);
217 217
218 - // Fetch download codes
219 - let db_download_codes = db::download_codes::get_download_codes_by_item(&state.db, item_id).await?;
218 + // Fetch promo codes scoped to this item
219 + let db_promo_codes = db::promo_codes::get_promo_codes_by_item(&state.db, item_id).await?;
220 220
221 221 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
222 222 let license_keys: Vec<LicenseKeyRow> = db_license_keys.into_iter().map(LicenseKeyRow::from).collect();
223 - let download_codes: Vec<DownloadCodeRow> = db_download_codes.into_iter().map(DownloadCodeRow::from).collect();
223 + let promo_codes: Vec<PromoCodeRow> = db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
224 224
225 225 Ok(DashboardItemTemplate {
226 226 csrf_token,
@@ -229,7 +229,7 @@ pub(super) async fn dashboard_item(
229 229 project_title: db_project.title,
230 230 versions,
231 231 license_keys,
232 - download_codes,
232 + promo_codes,
233 233 })
234 234 }
235 235
@@ -267,7 +267,7 @@ pub(super) async fn project_tab_promotions(
267 267 .await?
268 268 .ok_or(AppError::NotFound)?;
269 269
270 - let codes = db::discount_codes::get_discount_codes_by_project(&state.db, db_project.id).await?;
270 + let codes = db::promo_codes::get_promo_codes_by_project(&state.db, db_project.id).await?;
271 271 let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
272 272
273 273 let items: Vec<ContentItem> = db_items
@@ -279,7 +279,7 @@ pub(super) async fn project_tab_promotions(
279 279 Ok(ProjectPromotionsTabTemplate {
280 280 project_id: db_project.id.to_string(),
281 281 project_slug: db_project.slug.to_string(),
282 - discount_codes: codes.into_iter().map(DiscountCodeRow::from).collect(),
282 + promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
283 283 items,
284 284 })
285 285 }
@@ -9,7 +9,7 @@ use serde::Deserialize;
9 9
10 10 use crate::{
11 11 auth::AuthUser,
12 - db::{self, DiscountCodeId, ItemId, SubscriptionTierId},
12 + db::{self, CodePurpose, ItemId, PromoCodeId, SubscriptionTierId},
13 13 error::{AppError, Result},
14 14 helpers,
15 15 AppState,
@@ -85,47 +85,83 @@ pub(super) async fn create_checkout(
85 85 item.price_cents
86 86 };
87 87
88 - // Validate optional discount code
88 + // Validate optional promo code (discount or free_access)
89 89 let mut final_price_cents = base_price_cents;
90 - let mut discount_code_id: Option<DiscountCodeId> = None;
90 + let mut promo_code_id: Option<PromoCodeId> = None;
91 91
92 92 if let Some(code_str) = form.discount_code.as_deref() {
93 93 let code_str = code_str.trim().to_uppercase();
94 94 if !code_str.is_empty() {
95 - let dc = db::discount_codes::get_discount_code_by_seller_and_code(&state.db, seller_id, &code_str)
95 + let pc = db::promo_codes::get_promo_code_by_creator_and_code(&state.db, seller_id, &code_str)
96 96 .await?
97 97 .ok_or_else(|| AppError::BadRequest("Invalid discount code".to_string()))?;
98 98
99 - // Check expiry
100 - if let Some(expires) = dc.expires_at && expires < chrono::Utc::now() {
101 - return Err(AppError::BadRequest("This discount code has expired".to_string()));
102 - }
99 + // Only discount and free_access codes are valid at item checkout
100 + match pc.code_purpose {
101 + CodePurpose::FreeTrial => {
102 + return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string()));
103 + }
104 + CodePurpose::FreeAccess => {
105 + // free_access = 100% discount
106 + final_price_cents = 0;
107 + promo_code_id = Some(pc.id);
108 + }
109 + CodePurpose::Discount => {
110 + // Check expiry
111 + if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() {
112 + return Err(AppError::BadRequest("This discount code has expired".to_string()));
113 + }
103 114
104 - // Check max uses
105 - if let Some(max) = dc.max_uses && dc.use_count >= max {
106 - return Err(AppError::BadRequest("This discount code has reached its usage limit".to_string()));
107 - }
115 + // Check max uses
116 + if let Some(max) = pc.max_uses && pc.use_count >= max {
117 + return Err(AppError::BadRequest("This discount code has reached its usage limit".to_string()));
118 + }
108 119
109 - // Check item scope
110 - if let Some(scoped_item) = dc.item_id && scoped_item != item_uuid {
111 - return Err(AppError::BadRequest("This discount code is not valid for this item".to_string()));
112 - }
120 + // Check item scope
121 + if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid {
122 + return Err(AppError::BadRequest("This discount code is not valid for this item".to_string()));
123 + }
113 124
114 - // Check project scope
115 - if let Some(scoped_project) = dc.project_id && item.project_id != scoped_project {
116 - return Err(AppError::BadRequest("This discount code is not valid for this item".to_string()));
117 - }
125 + // Check project scope
126 + if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project {
127 + return Err(AppError::BadRequest("This discount code is not valid for this item".to_string()));
128 + }
118 129
119 - // Check minimum price
120 - if item.price_cents < dc.min_price_cents {
121 - return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string()));
130 + // Check minimum price
131 + if item.price_cents < pc.min_price_cents {
132 + return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string()));
133 + }
134 +
135 + // Discount applies to the seller's list price, not the buyer's PWYW amount.
136 + if let (Some(dt), Some(dv)) = (pc.discount_type, pc.discount_value) {
137 + final_price_cents = db::promo_codes::apply_discount(item.price_cents, dt, dv);
138 + }
139 + promo_code_id = Some(pc.id);
140 + }
122 141 }
123 142
124 - // Discount applies to the seller's list price, not the buyer's PWYW amount.
125 - // This is intentional: discount codes are seller promotions off the listed price.
126 - // For PWYW items, the discounted list price becomes the final charge.
127 - final_price_cents = db::discount_codes::apply_discount(item.price_cents, dc.discount_type, dc.discount_value);
128 - discount_code_id = Some(dc.id);
143 + // Common checks for non-trial codes
144 + if pc.code_purpose != CodePurpose::Discount {
145 + // Check expiry
146 + if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() {
147 + return Err(AppError::BadRequest("This code has expired".to_string()));
148 + }
149 +
150 + // Check max uses
151 + if let Some(max) = pc.max_uses && pc.use_count >= max {
152 + return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
153 + }
154 +
155 + // Check item scope
156 + if let Some(scoped_item) = pc.item_id && scoped_item != item_uuid {
157 + return Err(AppError::BadRequest("This code is not valid for this item".to_string()));
158 + }
159 +
160 + // Check project scope
161 + if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project {
162 + return Err(AppError::BadRequest("This code is not valid for this item".to_string()));
163 + }
164 + }
129 165 }
130 166 }
131 167
@@ -140,16 +176,16 @@ pub(super) async fn create_checkout(
140 176 share_contact: form.share_contact,
141 177 };
142 178
143 - let claimed = if let Some(dc_id) = discount_code_id {
144 - // Wrap discount code increment + claim + sales count in a single transaction
145 - let (code_accepted, claimed) = db::transactions::claim_free_with_discount_code(
179 + let claimed = if let Some(pc_id) = promo_code_id {
180 + // Wrap promo code increment + claim + sales count in a single transaction
181 + let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code(
146 182 &state.db,
147 - dc_id,
183 + pc_id,
148 184 &claim,
149 185 ).await?;
150 186
151 187 if !code_accepted {
152 - return Err(AppError::BadRequest("This discount code has reached its usage limit".to_string()));
188 + return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
153 189 }
154 190 claimed
155 191 } else {
@@ -252,7 +288,7 @@ pub(super) async fn create_checkout(
252 288 item_id: item_uuid,
253 289 success_url: &success_url,
254 290 cancel_url: &cancel_url,
255 - discount_code_id,
291 + promo_code_id,
256 292 };
257 293 let session = stripe.create_checkout_session(&checkout_params).await?;
258 294
@@ -279,12 +315,19 @@ pub(super) async fn create_checkout(
279 315 Ok(Redirect::to(&checkout_url).into_response())
280 316 }
281 317
318 + /// Form data for subscription checkout (supports optional promo code).
319 + #[derive(Debug, Deserialize)]
320 + pub(super) struct SubscribeForm {
321 + promo_code: Option<String>,
322 + }
323 +
282 324 /// POST /stripe/subscribe/{tier_id} - Create a subscription checkout and redirect
283 325 #[tracing::instrument(skip_all, name = "stripe::subscribe")]
284 326 pub(super) async fn create_subscription_checkout(
285 327 State(state): State<AppState>,
286 328 AuthUser(user): AuthUser,
287 329 Path(tier_id): Path<String>,
330 + Form(form): Form<SubscribeForm>,
288 331 ) -> Result<Response> {
289 332 let tier_uuid: SubscriptionTierId = tier_id.parse()
290 333 .map_err(|_| AppError::NotFound)?;
@@ -326,6 +369,46 @@ pub(super) async fn create_subscription_checkout(
326 369 let stripe = state.stripe.as_ref()
327 370 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
328 371
372 + // Validate optional promo code for free trial
373 + let mut trial_days: Option<i32> = None;
374 + let mut promo_code_id: Option<PromoCodeId> = None;
375 +
376 + if let Some(code_str) = form.promo_code.as_deref() {
377 + let code_str = code_str.trim().to_uppercase();
378 + if !code_str.is_empty() {
379 + let pc = db::promo_codes::get_promo_code_by_creator_and_code(&state.db, project.user_id, &code_str)
380 + .await?
381 + .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
382 +
383 + if pc.code_purpose != CodePurpose::FreeTrial {
384 + return Err(AppError::BadRequest("This code is not a free trial code".to_string()));
385 + }
386 +
387 + // Check expiry
388 + if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() {
389 + return Err(AppError::BadRequest("This code has expired".to_string()));
390 + }
391 +
392 + // Check max uses
393 + if let Some(max) = pc.max_uses && pc.use_count >= max {
394 + return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
395 + }
396 +
397 + // Check tier scope
398 + if let Some(scoped_tier) = pc.tier_id && scoped_tier != tier_uuid {
399 + return Err(AppError::BadRequest("This code is not valid for this tier".to_string()));
400 + }
401 +
402 + // Check project scope
403 + if let Some(scoped_project) = pc.project_id && tier.project_id != scoped_project {
404 + return Err(AppError::BadRequest("This code is not valid for this project".to_string()));
405 + }
406 +
407 + trial_days = pc.trial_days;
408 + promo_code_id = Some(pc.id);
409 + }
410 + }
411 +
329 412 // Build URLs
330 413 let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
331 414 let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug);
@@ -340,6 +423,8 @@ pub(super) async fn create_subscription_checkout(
340 423 tier_id: tier_uuid,
341 424 success_url: &success_url,
342 425 cancel_url: &cancel_url,
426 + trial_days,
427 + promo_code_id,
343 428 },
344 429 ).await?;
345 430
@@ -104,7 +104,7 @@ async fn handle_purchase_checkout_completed(
104 104 let buyer_id = raw_metadata.buyer_id;
105 105 let seller_id = raw_metadata.seller_id;
106 106 let item_id = raw_metadata.item_id;
107 - let discount_code_id = raw_metadata.discount_code_id;
107 + let promo_code_id = raw_metadata.promo_code_id;
108 108
109 109 // Get the payment intent ID
110 110 let payment_intent_id = session.payment_intent
@@ -127,10 +127,10 @@ async fn handle_purchase_checkout_completed(
127 127 // Increment denormalized sales_count (inside transaction)
128 128 db::items::increment_sales_count(&mut *db_tx, item_id).await?;
129 129
130 - // Increment discount code use_count if one was used (inside transaction).
130 + // Increment promo code use_count if one was used (inside transaction).
131 131 // Errors propagate so the entire transaction rolls back together.
132 - if let Some(dc_id) = discount_code_id {
133 - db::discount_codes::try_increment_discount_code_use_count(&mut *db_tx, dc_id).await?;
132 + if let Some(pc_id) = promo_code_id {
133 + db::promo_codes::try_increment_use_count(&mut *db_tx, pc_id).await?;
134 134 }
135 135
136 136 // Commit the critical data integrity operations
@@ -297,6 +297,11 @@ async fn handle_subscription_checkout_completed(
297 297 }
298 298 };
299 299
300 + // Increment promo code use_count if one was used
301 + if let Some(pc_id) = raw_metadata.promo_code_id {
302 + let _ = db::promo_codes::try_increment_use_count(&state.db, pc_id).await;
303 + }
304 +
300 305 tracing::info!(
301 306 subscription_id = %sub.id, subscriber_id = %subscriber_id, project_id = %project_id, tier_id = %tier_id,
302 307 "subscription created"