max / makenotwork
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" |