Skip to main content

max / makenotwork

23.7 KB · 741 lines History Blame Raw
1 //! Unified promo code management API for creators and public claim endpoint.
2
3 use axum::{
4 Form, Json,
5 extract::{Path, State},
6 http::{StatusCode, header::HeaderMap},
7 response::{IntoResponse, Response},
8 };
9 use serde::{Deserialize, Serialize};
10
11 use sqlx::PgPool;
12
13 use crate::{
14 auth::AuthUser,
15 db::{self, CodePurpose, DiscountType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId},
16 error::{AppError, Result},
17 helpers::{self, hx_toast, is_htmx_request},
18 templates::PromoCodesListTemplate,
19 types::ListResponse,
20 types::PromoCodeRow,
21 };
22
23 use super::verify_item_ownership;
24
25 /// JSON response representing a promo code.
26 #[derive(Debug, Serialize)]
27 struct PromoCodeResponse {
28 id: PromoCodeId,
29 code: String,
30 code_purpose: CodePurpose,
31 discount_type: Option<DiscountType>,
32 discount_value: Option<i32>,
33 trial_days: Option<i32>,
34 max_uses: Option<i32>,
35 use_count: i32,
36 }
37
38 /// JSON response for a free_access code claim.
39 #[derive(Debug, Serialize)]
40 struct ClaimPromoCodeResponse {
41 success: bool,
42 already_owned: bool,
43 item_id: ItemId,
44 }
45
46 // Creator management (auth required)
47
48 /// Form input for creating a promo code.
49 #[derive(Debug, Deserialize)]
50 pub(super) 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 /// Optional start date (HTML date input: YYYY-MM-DD).
60 pub starts_at: Option<String>,
61 pub item_id: Option<String>,
62 pub project_id: Option<String>,
63 pub tier_id: Option<String>,
64 }
65
66 /// Create a new promo code (creator dashboard).
67 #[tracing::instrument(skip_all, name = "promo_codes::create_promo_code")]
68 pub(super) async fn create_promo_code(
69 State(db): State<PgPool>,
70 headers: HeaderMap,
71 AuthUser(user): AuthUser,
72 Form(req): Form<CreatePromoCodeForm>,
73 ) -> Result<Response> {
74 user.check_not_suspended()?;
75
76 // Generate or validate code
77 let code = match req.code_purpose {
78 CodePurpose::FreeAccess => {
79 // Auto-generate word-based code for free_access (keep lowercase)
80 if let Some(ref c) = req.code {
81 let c = c.trim().to_string();
82 if c.is_empty() {
83 helpers::generate_key_code().into_inner()
84 } else if c.len() > 100 {
85 return Err(AppError::BadRequest(
86 "Code must be at most 100 characters".to_string(),
87 ));
88 } else {
89 c
90 }
91 } else {
92 helpers::generate_key_code().into_inner()
93 }
94 }
95 _ => {
96 let code = req.code.as_deref().unwrap_or("").trim().to_uppercase();
97 if code.is_empty() || code.len() > 50 {
98 return Err(AppError::BadRequest(
99 "Code must be 1-50 characters".to_string(),
100 ));
101 }
102 // Restrict to an unambiguous coupon-code charset. Besides being what
103 // users expect to type, this keeps HTML metacharacters out of the
104 // code so it can never carry markup into the redemptions modal
105 // (defense-in-depth alongside escaping at the JS sink).
106 if !code
107 .chars()
108 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
109 {
110 return Err(AppError::BadRequest(
111 "Code may only contain letters, numbers, hyphens, and underscores".to_string(),
112 ));
113 }
114 code
115 }
116 };
117
118 // Validate purpose-specific fields
119 match req.code_purpose {
120 CodePurpose::Discount => {
121 let dt = req
122 .discount_type
123 .ok_or_else(|| AppError::BadRequest("Discount type is required".to_string()))?;
124 let dv = req
125 .discount_value
126 .ok_or_else(|| AppError::BadRequest("Discount value is required".to_string()))?;
127 match dt {
128 DiscountType::Percentage => {
129 if !(1..=100).contains(&dv) {
130 return Err(AppError::BadRequest("Percentage must be 1-100".to_string()));
131 }
132 }
133 DiscountType::Fixed => {
134 if dv < 1 {
135 return Err(AppError::BadRequest(
136 "Fixed discount must be at least 1 cent".to_string(),
137 ));
138 }
139 if dv > crate::constants::MAX_PRICE_CENTS {
140 return Err(AppError::BadRequest(format!(
141 "Fixed discount must be at most {}",
142 crate::formatting::format_revenue(
143 crate::constants::MAX_PRICE_CENTS as i64
144 )
145 )));
146 }
147 }
148 }
149 }
150 CodePurpose::FreeTrial => {
151 let days = req
152 .trial_days
153 .ok_or_else(|| AppError::BadRequest("Trial days is required".to_string()))?;
154 if days < 1 {
155 return Err(AppError::BadRequest(
156 "Trial days must be at least 1".to_string(),
157 ));
158 }
159 if days > 365 {
160 return Err(AppError::BadRequest(
161 "Trial days must be at most 365".to_string(),
162 ));
163 }
164 }
165 CodePurpose::FreeAccess => {
166 // No extra validation needed
167 }
168 }
169
170 if let Some(max) = req.max_uses
171 && max < 1
172 {
173 return Err(AppError::BadRequest(
174 "Max uses must be at least 1".to_string(),
175 ));
176 }
177
178 // Parse optional item_id
179 let item_id = if let Some(ref id_str) = req.item_id {
180 let id_str = id_str.trim();
181 if id_str.is_empty() {
182 None
183 } else {
184 let item_id: ItemId = id_str
185 .parse()
186 .map_err(|_| AppError::BadRequest("Invalid item ID".to_string()))?;
187 verify_item_ownership(&db, item_id, user.id).await?;
188 Some(item_id)
189 }
190 } else {
191 None
192 };
193
194 // Parse optional project_id
195 let project_id = if let Some(ref id_str) = req.project_id {
196 let id_str = id_str.trim();
197 if id_str.is_empty() {
198 None
199 } else {
200 let pid: ProjectId = id_str
201 .parse()
202 .map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;
203 let project = db::projects::get_project_by_id(&db, pid)
204 .await?
205 .ok_or(AppError::NotFound)?;
206 if project.user_id != user.id {
207 return Err(AppError::Forbidden);
208 }
209 Some(pid)
210 }
211 } else {
212 None
213 };
214
215 // Parse optional tier_id (verify ownership via tier → project → user)
216 let tier_id = if let Some(ref id_str) = req.tier_id {
217 let id_str = id_str.trim();
218 if id_str.is_empty() {
219 None
220 } else {
221 let tid: SubscriptionTierId = id_str
222 .parse()
223 .map_err(|_| AppError::BadRequest("Invalid tier ID".to_string()))?;
224 let tier = db::subscriptions::get_subscription_tier_by_id(&db, tid)
225 .await?
226 .ok_or(AppError::NotFound)?;
227 let tier_project_id = tier
228 .project_id
229 .ok_or(AppError::BadRequest("Tier has no project".to_string()))?;
230 let tier_project = db::projects::get_project_by_id(&db, tier_project_id)
231 .await?
232 .ok_or(AppError::NotFound)?;
233 if tier_project.user_id != user.id {
234 return Err(AppError::Forbidden);
235 }
236 Some(tid)
237 }
238 } else {
239 None
240 };
241
242 // Parse optional expiry date (YYYY-MM-DD from HTML date input)
243 let expires_at = if let Some(ref date_str) = req.expires_at {
244 let date_str = date_str.trim();
245 if date_str.is_empty() {
246 None
247 } else {
248 let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
249 .map_err(|_| AppError::BadRequest("Invalid expiry date".to_string()))?;
250 Some(
251 date.and_hms_opt(23, 59, 59)
252 .expect("23:59:59 is a valid time")
253 .and_utc(),
254 )
255 }
256 } else {
257 None
258 };
259
260 // Parse optional start date (YYYY-MM-DD from HTML date input)
261 let starts_at = if let Some(ref date_str) = req.starts_at {
262 let date_str = date_str.trim();
263 if date_str.is_empty() {
264 None
265 } else {
266 let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
267 .map_err(|_| AppError::BadRequest("Invalid start date".to_string()))?;
268 Some(
269 date.and_hms_opt(0, 0, 0)
270 .expect("00:00:00 is a valid time")
271 .and_utc(),
272 )
273 }
274 } else {
275 None
276 };
277
278 // Validate starts_at < expires_at if both present
279 if let (Some(start), Some(end)) = (starts_at, expires_at)
280 && start >= end
281 {
282 return Err(AppError::BadRequest(
283 "Start date must be before expiry date".to_string(),
284 ));
285 }
286
287 // Reject already-expired codes
288 if let Some(exp) = expires_at
289 && exp < chrono::Utc::now()
290 {
291 return Err(AppError::BadRequest(
292 "Expiry date must be in the future".to_string(),
293 ));
294 }
295
296 let promo_code = match db::promo_codes::create_promo_code(
297 &db,
298 user.id,
299 &code,
300 req.code_purpose,
301 req.discount_type,
302 req.discount_value,
303 0, // min_price_cents defaults to 0
304 req.trial_days,
305 req.max_uses,
306 expires_at,
307 starts_at,
308 item_id,
309 project_id,
310 tier_id,
311 )
312 .await
313 {
314 Ok(pc) => pc,
315 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
316 if db_err.code().as_deref() == Some("23505") =>
317 {
318 return Err(AppError::BadRequest(
319 "A promo code with that name already exists".to_string(),
320 ));
321 }
322 Err(e) => return Err(e),
323 };
324
325 if let Some(pid) = project_id {
326 db::projects::bump_cache_generation(&db, pid).await?;
327 }
328
329 if is_htmx_request(&headers) {
330 // Return project-scoped codes if created from project context, otherwise creator-global
331 let codes = if let Some(pid) = project_id {
332 db::promo_codes::get_promo_codes_by_project(&db, pid).await?
333 } else {
334 db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?
335 };
336 return Ok((
337 [("HX-Trigger", hx_toast("Promo code created", "success"))],
338 PromoCodesListTemplate {
339 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
340 },
341 )
342 .into_response());
343 }
344
345 Ok(Json(PromoCodeResponse {
346 id: promo_code.id,
347 code: promo_code.code,
348 code_purpose: promo_code.code_purpose,
349 discount_type: promo_code.discount_type,
350 discount_value: promo_code.discount_value,
351 trial_days: promo_code.trial_days,
352 max_uses: promo_code.max_uses,
353 use_count: promo_code.use_count,
354 })
355 .into_response())
356 }
357
358 /// List all promo codes for the authenticated creator.
359 #[tracing::instrument(skip_all, name = "promo_codes::list_promo_codes")]
360 pub(super) async fn list_promo_codes(
361 State(db): State<PgPool>,
362 headers: HeaderMap,
363 AuthUser(user): AuthUser,
364 ) -> Result<Response> {
365 let codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?;
366
367 if is_htmx_request(&headers) {
368 return Ok(PromoCodesListTemplate {
369 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
370 }
371 .into_response());
372 }
373
374 let data: Vec<PromoCodeResponse> = codes
375 .into_iter()
376 .map(|c| PromoCodeResponse {
377 id: c.id,
378 code: c.code,
379 code_purpose: c.code_purpose,
380 discount_type: c.discount_type,
381 discount_value: c.discount_value,
382 trial_days: c.trial_days,
383 max_uses: c.max_uses,
384 use_count: c.use_count,
385 })
386 .collect();
387
388 Ok(Json(ListResponse { data }).into_response())
389 }
390
391 /// List redemptions of a promo code (creator dashboard).
392 ///
393 /// Authenticated; the caller must own the code. Returns at most 500 rows of
394 /// `(redeemed_at, buyer, item, amount)`; guest checkouts surface as the
395 /// guest's email with `username = None`. Codes that exceed 500 redemptions
396 /// should be exported via the CSV flow (separate endpoint, not built yet,
397 /// log a TODO if you hit it).
398 #[tracing::instrument(skip_all, name = "promo_codes::list_redemptions")]
399 pub(super) async fn list_redemptions(
400 State(db): State<PgPool>,
401 AuthUser(user): AuthUser,
402 Path(code_id): Path<PromoCodeId>,
403 ) -> Result<Response> {
404 let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id)
405 .await?
406 .ok_or(AppError::NotFound)?;
407
408 if promo_code.creator_id != user.id {
409 return Err(AppError::Forbidden);
410 }
411
412 let rows = db::promo_codes::list_redemptions(&db, code_id).await?;
413 Ok(Json(serde_json::json!({ "redemptions": rows })).into_response())
414 }
415
416 #[tracing::instrument(skip_all, name = "promo_codes::delete_promo_code")]
417 pub(super) async fn delete_promo_code(
418 State(db): State<PgPool>,
419 headers: HeaderMap,
420 AuthUser(user): AuthUser,
421 Path(code_id): Path<PromoCodeId>,
422 ) -> Result<Response> {
423 user.check_not_suspended()?;
424
425 let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id)
426 .await?
427 .ok_or(AppError::NotFound)?;
428
429 if promo_code.creator_id != user.id {
430 return Err(AppError::Forbidden);
431 }
432
433 let deleted_project_id = promo_code.project_id;
434 db::promo_codes::delete_promo_code(&db, code_id).await?;
435
436 if let Some(pid) = deleted_project_id {
437 db::projects::bump_cache_generation(&db, pid).await?;
438 }
439
440 if is_htmx_request(&headers) {
441 let codes = if let Some(pid) = deleted_project_id {
442 db::promo_codes::get_promo_codes_by_project(&db, pid).await?
443 } else {
444 db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?
445 };
446 return Ok((
447 [("HX-Trigger", hx_toast("Promo code deleted", "success"))],
448 PromoCodesListTemplate {
449 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
450 },
451 )
452 .into_response());
453 }
454
455 Ok(StatusCode::NO_CONTENT.into_response())
456 }
457
458 /// Form input for updating a promo code.
459 #[derive(Debug, Deserialize)]
460 pub(super) struct UpdatePromoCodeForm {
461 pub max_uses: Option<String>,
462 pub expires_at: Option<String>,
463 pub starts_at: Option<String>,
464 }
465
466 /// Update an existing promo code (expires_at, starts_at, max_uses only).
467 #[tracing::instrument(skip_all, name = "promo_codes::update_promo_code")]
468 pub(super) async fn update_promo_code(
469 State(db): State<PgPool>,
470 headers: HeaderMap,
471 AuthUser(user): AuthUser,
472 Path(code_id): Path<PromoCodeId>,
473 Form(req): Form<UpdatePromoCodeForm>,
474 ) -> Result<Response> {
475 user.check_not_suspended()?;
476
477 let promo_code = db::promo_codes::get_promo_code_by_id(&db, code_id)
478 .await?
479 .ok_or(AppError::NotFound)?;
480
481 if promo_code.creator_id != user.id {
482 return Err(AppError::Forbidden);
483 }
484
485 // Parse optional fields, empty string means clear, absent means no change
486 let parse_date = |s: &str| -> Result<Option<chrono::DateTime<chrono::Utc>>> {
487 let s = s.trim();
488 if s.is_empty() {
489 return Ok(None);
490 }
491 let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
492 .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?;
493 Ok(Some(
494 date.and_hms_opt(23, 59, 59)
495 .expect("23:59:59 is a valid time")
496 .and_utc(),
497 ))
498 };
499
500 let expires_at = req.expires_at.as_deref().map(parse_date).transpose()?;
501 let starts_at = req
502 .starts_at
503 .as_deref()
504 .map(|s| {
505 let s = s.trim();
506 if s.is_empty() {
507 return Ok(None);
508 }
509 let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
510 .map_err(|_| AppError::BadRequest("Invalid date format".to_string()))?;
511 Ok::<_, AppError>(Some(
512 date.and_hms_opt(0, 0, 0)
513 .expect("00:00:00 is a valid time")
514 .and_utc(),
515 ))
516 })
517 .transpose()?;
518
519 let max_uses = req
520 .max_uses
521 .as_deref()
522 .map(|s| {
523 let s = s.trim();
524 if s.is_empty() {
525 return Ok(None);
526 }
527 let n: i32 = s
528 .parse()
529 .map_err(|_| AppError::BadRequest("Invalid max uses".to_string()))?;
530 if n < 1 {
531 return Err(AppError::BadRequest(
532 "Max uses must be at least 1".to_string(),
533 ));
534 }
535 if n < promo_code.use_count {
536 return Err(AppError::BadRequest(format!(
537 "max_uses cannot be less than current use_count ({})",
538 promo_code.use_count
539 )));
540 }
541 Ok::<_, AppError>(Some(n))
542 })
543 .transpose()?;
544
545 db::promo_codes::update_promo_code(&db, code_id, expires_at, starts_at, max_uses).await?;
546
547 if let Some(pid) = promo_code.project_id {
548 db::projects::bump_cache_generation(&db, pid).await?;
549 }
550
551 if is_htmx_request(&headers) {
552 let codes = if let Some(pid) = promo_code.project_id {
553 db::promo_codes::get_promo_codes_by_project(&db, pid).await?
554 } else {
555 db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?
556 };
557 return Ok((
558 [("HX-Trigger", hx_toast("Promo code updated", "success"))],
559 PromoCodesListTemplate {
560 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
561 },
562 )
563 .into_response());
564 }
565
566 Ok(StatusCode::NO_CONTENT.into_response())
567 }
568
569 /// Delete all expired promo codes for this creator.
570 #[tracing::instrument(skip_all, name = "promo_codes::delete_expired")]
571 pub(super) async fn delete_expired_promo_codes(
572 State(db): State<PgPool>,
573 headers: HeaderMap,
574 AuthUser(user): AuthUser,
575 ) -> Result<Response> {
576 user.check_not_suspended()?;
577
578 let count = db::promo_codes::delete_expired_by_creator(&db, user.id).await?;
579
580 if is_htmx_request(&headers) {
581 let codes = db::promo_codes::get_promo_codes_by_creator(&db, user.id).await?;
582 return Ok((
583 [(
584 "HX-Trigger",
585 hx_toast(&format!("{count} expired code(s) deleted"), "success"),
586 )],
587 PromoCodesListTemplate {
588 promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
589 },
590 )
591 .into_response());
592 }
593
594 Ok(Json(serde_json::json!({ "deleted": count })).into_response())
595 }
596
597 // Public claim (auth required, rate-limited)
598
599 /// Form/JSON input for claiming a free_access promo code.
600 #[derive(Debug, Deserialize)]
601 pub(super) struct ClaimPromoCodeForm {
602 pub code: db::KeyCode,
603 }
604
605 /// Claim a free_access promo code: validates the code and grants free access to the item.
606 #[tracing::instrument(skip_all, name = "api::claim_promo_code")]
607 pub(super) async fn claim_promo_code(
608 State(db): State<PgPool>,
609 headers: HeaderMap,
610 AuthUser(user): AuthUser,
611 Form(req): Form<ClaimPromoCodeForm>,
612 ) -> Result<Response> {
613 user.check_not_suspended()?;
614 user.check_not_sandbox()?;
615
616 let is_htmx = is_htmx_request(&headers);
617
618 // Look up the code
619 let promo_code = db::promo_codes::get_promo_code_by_code(&db, &req.code)
620 .await?
621 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
622
623 // Only free_access codes can be claimed this way
624 if promo_code.code_purpose != CodePurpose::FreeAccess {
625 return Err(AppError::BadRequest("Invalid promo code".to_string()));
626 }
627
628 // Must have an item scope
629 let item_id = promo_code
630 .item_id
631 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
632
633 // Check start date
634 if let Some(starts_at) = promo_code.starts_at
635 && starts_at > chrono::Utc::now()
636 {
637 return Err(AppError::BadRequest(
638 "This promo code is not yet active".to_string(),
639 ));
640 }
641
642 // Check expiration. Use `<=` so an exact `expires_at == NOW()` clock tick
643 // is treated as expired here, matches the SQL `expires_at > NOW()` guard
644 // in `try_increment_use_count`. Without the alignment, the route would
645 // accept a code right at the boundary, then the atomic SQL would reject
646 // it (rows_affected = 0) and the user gets the wrong error.
647 if let Some(expires_at) = promo_code.expires_at
648 && expires_at <= chrono::Utc::now()
649 {
650 return Err(AppError::BadRequest(
651 "This promo code has expired".to_string(),
652 ));
653 }
654
655 // Check usage limit
656 if let Some(max_uses) = promo_code.max_uses
657 && promo_code.use_count >= max_uses
658 {
659 return Err(AppError::BadRequest(
660 "This promo code has reached its usage limit".to_string(),
661 ));
662 }
663
664 // Get the item and its seller info for the transaction record
665 let item = db::items::get_item_by_id(&db, item_id)
666 .await?
667 .ok_or(AppError::NotFound)?;
668
669 if !item.is_public {
670 return Err(AppError::NotFound);
671 }
672
673 let project = db::projects::get_project_by_id(&db, item.project_id)
674 .await?
675 .ok_or(AppError::NotFound)?;
676
677 let seller = db::users::get_user_by_id(&db, project.user_id)
678 .await?
679 .ok_or(AppError::NotFound)?;
680
681 // Build license key params if the item has keys enabled
682 let key_code = if item.enable_license_keys {
683 Some(helpers::generate_key_code())
684 } else {
685 None
686 };
687 let lk_params = key_code
688 .as_ref()
689 .map(|kc| db::transactions::LicenseKeyParams {
690 key_code: kc,
691 max_activations: item.default_max_activations,
692 });
693
694 // Wrap promo code increment, claim, and license key in a single transaction
695 let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code(
696 &db,
697 promo_code.id,
698 &db::transactions::ClaimParams {
699 buyer_id: user.id,
700 item_id,
701 seller_id: project.user_id,
702 item_title: &item.title,
703 seller_username: &seller.username,
704 share_contact: false,
705 parent_transaction_id: None,
706 // Item-scoped seller free-access code (guarded above): creator-funded.
707 platform_credit_cents: 0,
708 },
709 lk_params.as_ref(),
710 )
711 .await?;
712
713 if !code_accepted {
714 return Err(AppError::BadRequest(
715 "This promo code has reached its usage limit".to_string(),
716 ));
717 }
718
719 if is_htmx {
720 return Ok((
721 [(
722 "HX-Trigger",
723 hx_toast("Item added to your library", "success"),
724 )],
725 Json(ClaimPromoCodeResponse {
726 success: true,
727 already_owned: !claimed,
728 item_id,
729 }),
730 )
731 .into_response());
732 }
733
734 Ok(Json(ClaimPromoCodeResponse {
735 success: true,
736 already_owned: !claimed,
737 item_id,
738 })
739 .into_response())
740 }
741