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