Skip to main content

max / makenotwork

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