Skip to main content

max / makenotwork

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