Skip to main content

max / makenotwork

33.6 KB · 1070 lines History Blame Raw
1 //! Creator tier subscription queries and storage enforcement.
2
3 use chrono::{DateTime, Utc};
4 use sqlx::PgPool;
5
6 use super::enums::{CreatorTier, SubscriptionStatus};
7 use super::id_types::*;
8 use super::models::{DbCreatorSubscription, StorageBreakdown};
9 use crate::error::{AppError, Result};
10 use crate::helpers::format_bytes;
11 use crate::storage::FileType;
12
13 /// Create or reactivate a creator tier subscription record.
14 ///
15 /// Uses ON CONFLICT DO UPDATE on the user_id unique index to handle
16 /// both duplicate webhooks and re-subscription after cancellation.
17 /// Returns `None` if the row already existed with the same stripe_subscription_id
18 /// (duplicate webhook), `Some` if this was a fresh insert or a re-subscription
19 /// with a different subscription ID.
20 #[tracing::instrument(skip_all)]
21 pub async fn create_creator_subscription<'e>(
22 executor: impl sqlx::PgExecutor<'e>,
23 user_id: UserId,
24 stripe_subscription_id: &str,
25 stripe_customer_id: &str,
26 tier: CreatorTier,
27 ) -> Result<Option<DbCreatorSubscription>> {
28 // Use WHERE clause on the DO UPDATE to only update if the subscription_id
29 // is different (new subscription) or status is not already active.
30 // When the WHERE fails, DO UPDATE becomes a no-op and RETURNING yields no row.
31 let sub = sqlx::query_as::<_, DbCreatorSubscription>(
32 r#"
33 INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier)
34 VALUES ($1, $2, $3, $4)
35 ON CONFLICT (user_id) DO UPDATE
36 SET stripe_subscription_id = EXCLUDED.stripe_subscription_id,
37 stripe_customer_id = EXCLUDED.stripe_customer_id,
38 tier = EXCLUDED.tier,
39 status = 'active',
40 canceled_at = NULL,
41 grace_enforced_at = NULL
42 WHERE creator_subscriptions.stripe_subscription_id != EXCLUDED.stripe_subscription_id
43 OR creator_subscriptions.status != 'active'
44 RETURNING *
45 "#,
46 )
47 .bind(user_id)
48 .bind(stripe_subscription_id)
49 .bind(stripe_customer_id)
50 .bind(tier)
51 .fetch_optional(executor)
52 .await?;
53
54 Ok(sub)
55 }
56
57 /// Look up a creator subscription by its Stripe subscription ID.
58 #[tracing::instrument(skip_all)]
59 pub async fn get_creator_sub_by_stripe_id(
60 pool: &PgPool,
61 stripe_subscription_id: &str,
62 ) -> Result<Option<DbCreatorSubscription>> {
63 let sub = sqlx::query_as::<_, DbCreatorSubscription>(
64 "SELECT * FROM creator_subscriptions WHERE stripe_subscription_id = $1",
65 )
66 .bind(stripe_subscription_id)
67 .fetch_optional(pool)
68 .await?;
69
70 Ok(sub)
71 }
72
73 /// Get a user's creator subscription (any status).
74 #[tracing::instrument(skip_all)]
75 pub async fn get_creator_sub_by_user(
76 pool: &PgPool,
77 user_id: UserId,
78 ) -> Result<Option<DbCreatorSubscription>> {
79 let sub = sqlx::query_as::<_, DbCreatorSubscription>(
80 "SELECT * FROM creator_subscriptions WHERE user_id = $1",
81 )
82 .bind(user_id)
83 .fetch_optional(pool)
84 .await?;
85
86 Ok(sub)
87 }
88
89 /// Get the active creator tier for a user (None if no active subscription).
90 #[tracing::instrument(skip_all)]
91 pub async fn get_active_creator_tier(
92 pool: &PgPool,
93 user_id: UserId,
94 ) -> Result<Option<CreatorTier>> {
95 let tier = sqlx::query_scalar::<_, String>(
96 "SELECT tier FROM creator_subscriptions WHERE user_id = $1 AND status = 'active'",
97 )
98 .bind(user_id)
99 .fetch_optional(pool)
100 .await?;
101
102 Ok(tier.and_then(|t| t.parse().ok()))
103 }
104
105 /// Update the status of a creator subscription.
106 /// Sets canceled_at when transitioning to canceled, preserving existing value.
107 #[tracing::instrument(skip_all)]
108 pub async fn update_creator_sub_status<'e>(
109 executor: impl sqlx::PgExecutor<'e>,
110 stripe_subscription_id: &str,
111 status: SubscriptionStatus,
112 ) -> Result<Option<DbCreatorSubscription>> {
113 let sub = sqlx::query_as::<_, DbCreatorSubscription>(
114 r#"
115 UPDATE creator_subscriptions
116 SET status = $2,
117 canceled_at = CASE
118 WHEN $2 = 'canceled' THEN COALESCE(canceled_at, NOW())
119 ELSE canceled_at
120 END
121 WHERE stripe_subscription_id = $1
122 RETURNING *
123 "#,
124 )
125 .bind(stripe_subscription_id)
126 .bind(status)
127 .fetch_optional(executor)
128 .await?;
129
130 Ok(sub)
131 }
132
133 /// Update the billing period of a creator subscription.
134 #[tracing::instrument(skip_all)]
135 pub async fn update_creator_sub_period<'e>(
136 executor: impl sqlx::PgExecutor<'e>,
137 stripe_subscription_id: &str,
138 start: DateTime<Utc>,
139 end: DateTime<Utc>,
140 ) -> Result<()> {
141 sqlx::query(
142 r#"
143 UPDATE creator_subscriptions
144 SET current_period_start = $2, current_period_end = $3
145 WHERE stripe_subscription_id = $1
146 "#,
147 )
148 .bind(stripe_subscription_id)
149 .bind(start)
150 .bind(end)
151 .execute(executor)
152 .await?;
153
154 Ok(())
155 }
156
157 /// Cancel a creator subscription (set status + canceled_at).
158 #[tracing::instrument(skip_all)]
159 pub async fn cancel_creator_sub(
160 pool: &PgPool,
161 stripe_subscription_id: &str,
162 ) -> Result<Option<DbCreatorSubscription>> {
163 let sub = sqlx::query_as::<_, DbCreatorSubscription>(
164 r#"
165 UPDATE creator_subscriptions
166 SET status = 'canceled', canceled_at = COALESCE(canceled_at, NOW())
167 WHERE stripe_subscription_id = $1
168 RETURNING *
169 "#,
170 )
171 .bind(stripe_subscription_id)
172 .fetch_optional(pool)
173 .await?;
174
175 Ok(sub)
176 }
177
178 /// Sync the users.creator_tier column from the subscription status.
179 /// Called after checkout/update/cancel to keep the denormalized column in sync.
180 #[tracing::instrument(skip_all)]
181 pub async fn sync_user_creator_tier(pool: &PgPool, user_id: UserId) -> Result<()> {
182 sqlx::query(
183 r#"
184 UPDATE users SET creator_tier = (
185 SELECT tier FROM creator_subscriptions
186 WHERE user_id = $1 AND status = 'active'
187 )
188 WHERE id = $1
189 "#,
190 )
191 .bind(user_id)
192 .execute(pool)
193 .await?;
194
195 Ok(())
196 }
197
198 // ============================================================================
199 // Storage tracking
200 // ============================================================================
201
202 /// Get the current storage_used_bytes for a user.
203 #[tracing::instrument(skip_all)]
204 pub async fn get_storage_used(pool: &PgPool, user_id: UserId) -> Result<i64> {
205 let used: i64 = sqlx::query_scalar(
206 "SELECT storage_used_bytes FROM users WHERE id = $1",
207 )
208 .bind(user_id)
209 .fetch_one(pool)
210 .await?;
211
212 Ok(used)
213 }
214
215 /// Atomically check storage cap and increment the user's storage counter.
216 /// Returns an error if the increment would exceed `max_storage_bytes`.
217 #[tracing::instrument(skip_all)]
218 pub async fn try_increment_storage(
219 pool: &PgPool,
220 user_id: UserId,
221 bytes: i64,
222 max_storage_bytes: i64,
223 ) -> Result<()> {
224 let result = sqlx::query(
225 "UPDATE users SET storage_used_bytes = storage_used_bytes + $2 \
226 WHERE id = $1 AND storage_used_bytes + $2 <= $3",
227 )
228 .bind(user_id)
229 .bind(bytes)
230 .bind(max_storage_bytes)
231 .execute(pool)
232 .await?;
233
234 if result.rows_affected() == 0 {
235 let used = get_storage_used(pool, user_id).await?;
236 return Err(AppError::BadRequest(format!(
237 "You've used {} of {} storage. Delete files or upgrade your tier.",
238 format_bytes(used),
239 format_bytes(max_storage_bytes),
240 )));
241 }
242
243 Ok(())
244 }
245
246 /// Transaction-friendly variant of [`try_increment_storage`]. Runs both the
247 /// cap-checked UPDATE and the error-path SELECT against the supplied
248 /// connection, so callers can wrap the increment in the same transaction as
249 /// the follow-up entity write (e.g. `media_files::create`). On cap miss the
250 /// transaction is left open for the caller to roll back via drop.
251 #[tracing::instrument(skip_all)]
252 pub async fn try_increment_storage_on(
253 conn: &mut sqlx::PgConnection,
254 user_id: UserId,
255 bytes: i64,
256 max_storage_bytes: i64,
257 ) -> Result<()> {
258 let result = sqlx::query(
259 "UPDATE users SET storage_used_bytes = storage_used_bytes + $2 \
260 WHERE id = $1 AND storage_used_bytes + $2 <= $3",
261 )
262 .bind(user_id)
263 .bind(bytes)
264 .bind(max_storage_bytes)
265 .execute(&mut *conn)
266 .await?;
267
268 if result.rows_affected() == 0 {
269 let used: i64 = sqlx::query_scalar(
270 "SELECT storage_used_bytes FROM users WHERE id = $1",
271 )
272 .bind(user_id)
273 .fetch_one(&mut *conn)
274 .await?;
275 return Err(AppError::BadRequest(format!(
276 "You've used {} of {} storage. Delete files or upgrade your tier.",
277 format_bytes(used),
278 format_bytes(max_storage_bytes),
279 )));
280 }
281
282 Ok(())
283 }
284
285 /// Atomically replace storage: decrement old file size and increment new file size
286 /// in a single UPDATE. Prevents storage drift on file replacement by avoiding a
287 /// window where decrement and increment are separate operations.
288 #[tracing::instrument(skip_all)]
289 pub async fn try_replace_storage(
290 pool: &PgPool,
291 user_id: UserId,
292 old_bytes: i64,
293 new_bytes: i64,
294 max_storage_bytes: i64,
295 ) -> Result<()> {
296 let result = sqlx::query(
297 "UPDATE users SET storage_used_bytes = GREATEST(0, storage_used_bytes - $2) + $3 \
298 WHERE id = $1 AND GREATEST(0, storage_used_bytes - $2) + $3 <= $4",
299 )
300 .bind(user_id)
301 .bind(old_bytes)
302 .bind(new_bytes)
303 .bind(max_storage_bytes)
304 .execute(pool)
305 .await?;
306
307 if result.rows_affected() == 0 {
308 let used = get_storage_used(pool, user_id).await?;
309 return Err(AppError::BadRequest(format!(
310 "You've used {} of {} storage. Delete files or upgrade your tier.",
311 format_bytes(used),
312 format_bytes(max_storage_bytes),
313 )));
314 }
315
316 Ok(())
317 }
318
319 /// Atomically decrement the user's storage counter (clamped to 0).
320 #[tracing::instrument(skip_all)]
321 pub async fn decrement_storage_used<'e>(
322 executor: impl sqlx::PgExecutor<'e>,
323 user_id: UserId,
324 bytes: i64,
325 ) -> Result<()> {
326 sqlx::query(
327 "UPDATE users SET storage_used_bytes = GREATEST(0, storage_used_bytes - $2) WHERE id = $1",
328 )
329 .bind(user_id)
330 .bind(bytes)
331 .execute(executor)
332 .await?;
333
334 Ok(())
335 }
336
337 /// Get the admin-set per-file size override for a user.
338 #[tracing::instrument(skip_all)]
339 pub async fn get_max_file_override(pool: &PgPool, user_id: UserId) -> Result<Option<i64>> {
340 let val: Option<i64> = sqlx::query_scalar(
341 "SELECT max_file_override_bytes FROM users WHERE id = $1",
342 )
343 .bind(user_id)
344 .fetch_one(pool)
345 .await?;
346
347 Ok(val)
348 }
349
350 /// Set or clear the admin per-file size override.
351 #[tracing::instrument(skip_all)]
352 pub async fn set_max_file_override(
353 pool: &PgPool,
354 user_id: UserId,
355 bytes: Option<i64>,
356 ) -> Result<()> {
357 sqlx::query(
358 "UPDATE users SET max_file_override_bytes = $2 WHERE id = $1",
359 )
360 .bind(user_id)
361 .bind(bytes)
362 .execute(pool)
363 .await?;
364
365 Ok(())
366 }
367
368 /// Get the grandfathering deadline for a user.
369 #[tracing::instrument(skip_all)]
370 pub async fn get_grandfathered_until(
371 pool: &PgPool,
372 user_id: UserId,
373 ) -> Result<Option<DateTime<Utc>>> {
374 let val: Option<DateTime<Utc>> = sqlx::query_scalar(
375 "SELECT grandfathered_until FROM users WHERE id = $1",
376 )
377 .bind(user_id)
378 .fetch_one(pool)
379 .await?;
380
381 Ok(val)
382 }
383
384 /// Get a per-category storage breakdown for the creator dashboard (single query).
385 #[tracing::instrument(skip_all)]
386 pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<StorageBreakdown> {
387 let row: (i64, i64, i64, i64, i64, i64) = sqlx::query_as(
388 r#"
389 WITH audio_bytes AS (
390 SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
391 FROM items i JOIN projects p ON i.project_id = p.id
392 WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
393 ),
394 cover_bytes AS (
395 SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total
396 FROM items i JOIN projects p ON i.project_id = p.id
397 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
398 ),
399 version_bytes AS (
400 SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total
401 FROM versions v
402 JOIN items i ON v.item_id = i.id
403 JOIN projects p ON i.project_id = p.id
404 WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
405 ),
406 insertion_bytes AS (
407 SELECT COALESCE(SUM(file_size)::BIGINT, 0) AS total
408 FROM content_insertions WHERE user_id = $1
409 ),
410 video_bytes AS (
411 SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0) AS total
412 FROM items i JOIN projects p ON i.project_id = p.id
413 WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL
414 ),
415 media_bytes AS (
416 SELECT COALESCE(SUM(file_size_bytes)::BIGINT, 0) AS total
417 FROM media_files WHERE user_id = $1
418 )
419 SELECT
420 (SELECT total FROM audio_bytes),
421 (SELECT total FROM cover_bytes),
422 (SELECT total FROM version_bytes),
423 (SELECT total FROM insertion_bytes),
424 (SELECT total FROM video_bytes),
425 (SELECT total FROM media_bytes)
426 "#,
427 )
428 .bind(user_id)
429 .fetch_one(pool)
430 .await?;
431
432 Ok(StorageBreakdown {
433 audio_bytes: row.0,
434 cover_bytes: row.1,
435 download_bytes: row.2,
436 insertion_bytes: row.3,
437 video_bytes: row.4,
438 media_bytes: row.5,
439 total_bytes: row.0 + row.1 + row.2 + row.3 + row.4 + row.5,
440 })
441 }
442
443 /// Get user IDs of creators with canceled subscriptions 30+ days ago
444 /// whose items have not yet been hidden.
445 #[tracing::instrument(skip_all)]
446 pub async fn get_expired_grace_creators(pool: &PgPool) -> Result<Vec<UserId>> {
447 let ids: Vec<UserId> = sqlx::query_scalar(
448 r#"
449 SELECT user_id FROM creator_subscriptions
450 WHERE status = 'canceled'
451 AND canceled_at IS NOT NULL
452 AND canceled_at < NOW() - INTERVAL '30 days'
453 AND grace_enforced_at IS NULL
454 "#,
455 )
456 .fetch_all(pool)
457 .await?;
458
459 Ok(ids)
460 }
461
462 /// Mark a creator's post-grace enforcement as applied.
463 #[tracing::instrument(skip_all)]
464 pub async fn mark_grace_enforced(pool: &PgPool, user_id: UserId) -> Result<()> {
465 sqlx::query(
466 "UPDATE creator_subscriptions SET grace_enforced_at = NOW() WHERE user_id = $1",
467 )
468 .bind(user_id)
469 .execute(pool)
470 .await?;
471
472 Ok(())
473 }
474
475 /// Check whether a user is in the 30-day cancellation grace period.
476 ///
477 /// Returns `true` if the subscription is canceled but within 30 days of cancellation
478 /// and enforcement has not yet been applied.
479 #[tracing::instrument(skip_all)]
480 pub async fn is_in_grace_period(pool: &PgPool, user_id: UserId) -> Result<bool> {
481 let in_grace: bool = sqlx::query_scalar(
482 r#"
483 SELECT EXISTS(
484 SELECT 1 FROM creator_subscriptions
485 WHERE user_id = $1
486 AND status = 'canceled'
487 AND canceled_at IS NOT NULL
488 AND canceled_at > NOW() - INTERVAL '30 days'
489 AND grace_enforced_at IS NULL
490 )
491 "#,
492 )
493 .bind(user_id)
494 .fetch_one(pool)
495 .await?;
496
497 Ok(in_grace)
498 }
499
500 /// Batch-recalculate storage_used_bytes for ALL creators in a single query.
501 ///
502 /// Uses the same CTE logic as `recalculate_storage_used` but operates on all
503 /// creator users at once, avoiding the N+1 loop. Returns the number of rows updated.
504 #[tracing::instrument(skip_all)]
505 pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> {
506 let result = sqlx::query(
507 r#"
508 UPDATE users SET storage_used_bytes = totals.total
509 FROM (
510 SELECT u.id AS user_id,
511 COALESCE(audio.total, 0)
512 + COALESCE(cover.total, 0)
513 + COALESCE(video.total, 0)
514 + COALESCE(versions.total, 0)
515 + COALESCE(insertions.total, 0)
516 + COALESCE(media.total, 0) AS total
517 FROM users u
518 LEFT JOIN LATERAL (
519 SELECT SUM(i.audio_file_size_bytes)::BIGINT AS total
520 FROM items i JOIN projects p ON i.project_id = p.id
521 WHERE p.user_id = u.id AND i.audio_file_size_bytes IS NOT NULL
522 ) audio ON true
523 LEFT JOIN LATERAL (
524 SELECT SUM(i.cover_file_size_bytes)::BIGINT AS total
525 FROM items i JOIN projects p ON i.project_id = p.id
526 WHERE p.user_id = u.id AND i.cover_file_size_bytes IS NOT NULL
527 ) cover ON true
528 LEFT JOIN LATERAL (
529 SELECT SUM(i.video_file_size_bytes)::BIGINT AS total
530 FROM items i JOIN projects p ON i.project_id = p.id
531 WHERE p.user_id = u.id AND i.video_file_size_bytes IS NOT NULL
532 ) video ON true
533 LEFT JOIN LATERAL (
534 SELECT SUM(v.file_size_bytes)::BIGINT AS total
535 FROM versions v JOIN items i ON v.item_id = i.id JOIN projects p ON i.project_id = p.id
536 WHERE p.user_id = u.id AND v.file_size_bytes IS NOT NULL
537 ) versions ON true
538 LEFT JOIN LATERAL (
539 SELECT SUM(ci.file_size)::BIGINT AS total
540 FROM content_insertions ci WHERE ci.user_id = u.id
541 ) insertions ON true
542 LEFT JOIN LATERAL (
543 SELECT SUM(mf.file_size_bytes)::BIGINT AS total
544 FROM media_files mf WHERE mf.user_id = u.id
545 ) media ON true
546 WHERE u.can_create_projects = true
547 ) totals
548 WHERE users.id = totals.user_id AND users.storage_used_bytes IS DISTINCT FROM totals.total
549 "#,
550 )
551 .execute(pool)
552 .await?;
553
554 Ok(result.rows_affected())
555 }
556
557 // ============================================================================
558 // Enforcement
559 // ============================================================================
560
561 /// Check whether a file upload is allowed based on the user's tier, storage,
562 /// and grandfathering status. Returns the tier's `max_storage_bytes` on success
563 /// (for use with `try_increment_storage`), or an appropriate `AppError` if rejected.
564 ///
565 /// Covers and media images bypass tier checks but respect their size limits
566 /// enforced separately in the storage module.
567 #[tracing::instrument(skip_all)]
568 pub async fn check_upload_allowed(
569 pool: &PgPool,
570 user_id: UserId,
571 file_type: FileType,
572 file_size_bytes: i64,
573 ) -> Result<i64> {
574 // Covers and media images bypass per-file tier checks but still respect
575 // the storage cap. Look up the active tier (fallback to Basic cap).
576 if file_type == FileType::Cover || file_type == FileType::MediaImage {
577 let active_tier = get_active_creator_tier(pool, user_id).await?;
578 let max_storage = active_tier
579 .map(|t| t.max_storage_bytes())
580 .unwrap_or_else(|| CreatorTier::Basic.max_storage_bytes());
581 return Ok(max_storage);
582 }
583
584 // Resolve effective tier
585 let active_tier = get_active_creator_tier(pool, user_id).await?;
586 let grandfathered = get_grandfathered_until(pool, user_id).await?;
587
588 let effective_tier = match active_tier {
589 Some(tier) => Some(tier),
590 None => {
591 // Check grandfathering
592 if let Some(until) = grandfathered {
593 if Utc::now() < until {
594 Some(CreatorTier::SmallFiles) // grandfathered as SmallFiles-equivalent
595 } else {
596 None
597 }
598 } else {
599 None
600 }
601 }
602 };
603
604 // Grace period check (canceled sub, within 30 days)
605 // Use effective_tier so grandfathered users aren't blocked
606 if effective_tier.is_none() {
607 let in_grace = is_in_grace_period(pool, user_id).await?;
608 if in_grace {
609 return Err(AppError::BadRequest(
610 "Your creator subscription has been canceled. Re-subscribe to upload files.".to_string(),
611 ));
612 }
613 }
614
615 // No tier and not grandfathered → reject
616 let tier = match effective_tier {
617 Some(t) => t,
618 None => {
619 return Err(AppError::BadRequest(
620 "A creator tier subscription is required to upload files.".to_string(),
621 ));
622 }
623 };
624
625 // Basic tier is text-only (no non-cover uploads)
626 if !tier.allows_file_uploads() {
627 return Err(AppError::BadRequest(
628 "Basic tier is text-only. Upgrade to Small Files or higher to upload files.".to_string(),
629 ));
630 }
631
632 // Per-file size check
633 let max_override = get_max_file_override(pool, user_id).await?;
634 let max_file = max_override.unwrap_or(tier.max_file_bytes());
635 if file_size_bytes > max_file {
636 return Err(AppError::FileTooLarge(format!(
637 "File size ({}) exceeds the {} per-file limit of {}.",
638 format_bytes(file_size_bytes),
639 tier.label(),
640 format_bytes(max_file),
641 )));
642 }
643
644 // Storage cap pre-check (non-atomic fast-fail; the atomic enforcement
645 // happens in try_increment_storage after scanning completes)
646 let used = get_storage_used(pool, user_id).await?;
647 let max_storage = tier.max_storage_bytes();
648 if used + file_size_bytes > max_storage {
649 return Err(AppError::BadRequest(format!(
650 "You've used {} of {} storage. Delete files or upgrade your tier.",
651 format_bytes(used),
652 format_bytes(max_storage),
653 )));
654 }
655
656 Ok(max_storage)
657 }
658
659 /// Early presign-time check: reject if the user has no tier or is already at/over
660 /// their storage cap. This prevents generating presigned URLs that would always
661 /// fail at confirm time. Does NOT check file size (unknown at presign).
662 #[tracing::instrument(skip_all)]
663 pub async fn check_presign_allowed(
664 pool: &PgPool,
665 user_id: UserId,
666 file_type: FileType,
667 ) -> Result<()> {
668 // Covers and media images bypass tier checks
669 if file_type == FileType::Cover || file_type == FileType::MediaImage {
670 return Ok(());
671 }
672
673 let active_tier = get_active_creator_tier(pool, user_id).await?;
674 let grandfathered = get_grandfathered_until(pool, user_id).await?;
675
676 let effective_tier = match active_tier {
677 Some(tier) => Some(tier),
678 None => {
679 if let Some(until) = grandfathered {
680 if Utc::now() < until {
681 Some(CreatorTier::SmallFiles)
682 } else {
683 None
684 }
685 } else {
686 None
687 }
688 }
689 };
690
691 if effective_tier.is_none() {
692 let in_grace = is_in_grace_period(pool, user_id).await?;
693 if in_grace {
694 return Err(AppError::BadRequest(
695 "Your creator subscription has been canceled. Re-subscribe to upload files.".to_string(),
696 ));
697 }
698 return Err(AppError::BadRequest(
699 "A creator tier subscription is required to upload files.".to_string(),
700 ));
701 }
702
703 let tier = effective_tier.expect("guarded by is_none check above");
704 if !tier.allows_file_uploads() {
705 return Err(AppError::BadRequest(
706 "Basic tier is text-only. Upgrade to Small Files or higher to upload files.".to_string(),
707 ));
708 }
709
710 // Reject if already at/over storage cap
711 let used = get_storage_used(pool, user_id).await?;
712 let max_storage = tier.max_storage_bytes();
713 if used >= max_storage {
714 return Err(AppError::BadRequest(format!(
715 "You've used {} of {} storage. Delete files or upgrade your tier.",
716 format_bytes(used),
717 format_bytes(max_storage),
718 )));
719 }
720
721 Ok(())
722 }
723
724 /// Return the effective per-file size limit in bytes for this user, accounting
725 /// for their active tier and any admin override. Returns `None` for file types
726 /// that bypass tier checks (covers, media images).
727 #[tracing::instrument(skip_all)]
728 pub async fn get_effective_max_file_bytes(
729 pool: &PgPool,
730 user_id: UserId,
731 file_type: FileType,
732 ) -> Result<Option<u64>> {
733 if file_type == FileType::Cover || file_type == FileType::MediaImage {
734 return Ok(None);
735 }
736
737 let active_tier = get_active_creator_tier(pool, user_id).await?;
738 let grandfathered = get_grandfathered_until(pool, user_id).await?;
739
740 let effective_tier = match active_tier {
741 Some(tier) => tier,
742 None => {
743 if let Some(until) = grandfathered {
744 if Utc::now() < until {
745 CreatorTier::SmallFiles
746 } else {
747 return Ok(Some(file_type.max_size()));
748 }
749 } else {
750 return Ok(Some(file_type.max_size()));
751 }
752 }
753 };
754
755 let max_override = get_max_file_override(pool, user_id).await?;
756 let tier_limit = max_override.unwrap_or(effective_tier.max_file_bytes()) as u64;
757 Ok(Some(std::cmp::min(tier_limit, file_type.max_size())))
758 }
759
760 /// Get total known file sizes for a user (versions + content insertions).
761 /// Used by the account deletion form to show how much data will be removed.
762 #[tracing::instrument(skip_all)]
763 pub async fn get_user_content_size(pool: &PgPool, user_id: UserId) -> Result<i64> {
764 let version_size: i64 = sqlx::query_scalar(
765 r#"
766 SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0)
767 FROM versions v
768 JOIN items i ON v.item_id = i.id
769 JOIN projects p ON i.project_id = p.id
770 WHERE p.user_id = $1 AND v.s3_key IS NOT NULL
771 "#,
772 )
773 .bind(user_id)
774 .fetch_one(pool)
775 .await?;
776
777 let insertion_size: i64 = sqlx::query_scalar(
778 "SELECT COALESCE(SUM(file_size)::BIGINT, 0) FROM content_insertions WHERE user_id = $1",
779 )
780 .bind(user_id)
781 .fetch_one(pool)
782 .await?;
783
784 Ok(version_size + insertion_size)
785 }
786
787 #[cfg(test)]
788 mod tests {
789 use super::*;
790
791 // ── CreatorTier::label ───────────────────────────────────────────────
792
793 #[test]
794 fn label_basic() {
795 assert_eq!(CreatorTier::Basic.label(), "Basic");
796 }
797
798 #[test]
799 fn label_small_files() {
800 assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
801 }
802
803 #[test]
804 fn label_big_files() {
805 assert_eq!(CreatorTier::BigFiles.label(), "Big Files");
806 }
807
808 #[test]
809 fn label_everything() {
810 assert_eq!(CreatorTier::Everything.label(), "Everything");
811 }
812
813 // ── CreatorTier::price_cents ────────────────────────────────────────
814
815 #[test]
816 fn price_basic_is_ten_dollars() {
817 assert_eq!(CreatorTier::Basic.price_cents(), 1000);
818 }
819
820 #[test]
821 fn price_small_files_is_twenty_dollars() {
822 assert_eq!(CreatorTier::SmallFiles.price_cents(), 2000);
823 }
824
825 #[test]
826 fn price_big_files_is_thirty_dollars() {
827 assert_eq!(CreatorTier::BigFiles.price_cents(), 3000);
828 }
829
830 #[test]
831 fn price_everything_is_sixty_dollars() {
832 assert_eq!(CreatorTier::Everything.price_cents(), 6000);
833 }
834
835 #[test]
836 fn prices_are_strictly_increasing() {
837 let tiers = [
838 CreatorTier::Basic,
839 CreatorTier::SmallFiles,
840 CreatorTier::BigFiles,
841 CreatorTier::Everything,
842 ];
843 for pair in tiers.windows(2) {
844 assert!(
845 pair[0].price_cents() < pair[1].price_cents(),
846 "{:?} should cost less than {:?}",
847 pair[0],
848 pair[1],
849 );
850 }
851 }
852
853 // ── CreatorTier::max_file_bytes ─────────────────────────────────────
854
855 #[test]
856 fn max_file_basic_is_10mb() {
857 assert_eq!(CreatorTier::Basic.max_file_bytes(), 10 * 1024 * 1024);
858 }
859
860 #[test]
861 fn max_file_small_files_is_500mb() {
862 assert_eq!(CreatorTier::SmallFiles.max_file_bytes(), 500 * 1024 * 1024);
863 }
864
865 #[test]
866 fn max_file_big_files_is_20gb() {
867 assert_eq!(CreatorTier::BigFiles.max_file_bytes(), 20 * 1024 * 1024 * 1024);
868 }
869
870 #[test]
871 fn max_file_everything_matches_big_files() {
872 assert_eq!(
873 CreatorTier::Everything.max_file_bytes(),
874 CreatorTier::BigFiles.max_file_bytes(),
875 );
876 }
877
878 #[test]
879 fn max_file_bytes_non_decreasing() {
880 let tiers = [
881 CreatorTier::Basic,
882 CreatorTier::SmallFiles,
883 CreatorTier::BigFiles,
884 CreatorTier::Everything,
885 ];
886 for pair in tiers.windows(2) {
887 assert!(
888 pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
889 "{:?} file limit should not exceed {:?}",
890 pair[0],
891 pair[1],
892 );
893 }
894 }
895
896 // ── CreatorTier::max_storage_bytes ───────────────────────────────────
897
898 #[test]
899 fn max_storage_basic_is_50gb() {
900 assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
901 }
902
903 #[test]
904 fn max_storage_small_files_is_250gb() {
905 assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024);
906 }
907
908 #[test]
909 fn max_storage_big_files_is_500gb() {
910 assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
911 }
912
913 #[test]
914 fn max_storage_everything_matches_big_files() {
915 assert_eq!(
916 CreatorTier::Everything.max_storage_bytes(),
917 CreatorTier::BigFiles.max_storage_bytes(),
918 );
919 }
920
921 #[test]
922 fn max_storage_non_decreasing() {
923 let tiers = [
924 CreatorTier::Basic,
925 CreatorTier::SmallFiles,
926 CreatorTier::BigFiles,
927 CreatorTier::Everything,
928 ];
929 for pair in tiers.windows(2) {
930 assert!(
931 pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
932 "{:?} storage limit should not exceed {:?}",
933 pair[0],
934 pair[1],
935 );
936 }
937 }
938
939 // ── CreatorTier::allows_file_uploads ─────────────────────────────────
940
941 #[test]
942 fn basic_tier_disallows_file_uploads() {
943 assert!(!CreatorTier::Basic.allows_file_uploads());
944 }
945
946 #[test]
947 fn small_files_allows_file_uploads() {
948 assert!(CreatorTier::SmallFiles.allows_file_uploads());
949 }
950
951 #[test]
952 fn big_files_allows_file_uploads() {
953 assert!(CreatorTier::BigFiles.allows_file_uploads());
954 }
955
956 #[test]
957 fn everything_allows_file_uploads() {
958 assert!(CreatorTier::Everything.allows_file_uploads());
959 }
960
961 // ── format_bytes helper ─────────────────────────────────────────────
962
963 #[test]
964 fn format_bytes_zero() {
965 assert_eq!(format_bytes(0), "0 B");
966 }
967
968 #[test]
969 fn format_bytes_one_byte() {
970 assert_eq!(format_bytes(1), "1 B");
971 }
972
973 #[test]
974 fn format_bytes_below_kb() {
975 assert_eq!(format_bytes(1023), "1023 B");
976 }
977
978 #[test]
979 fn format_bytes_exactly_1kb() {
980 assert_eq!(format_bytes(1024), "1.0 KB");
981 }
982
983 #[test]
984 fn format_bytes_exactly_1mb() {
985 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
986 }
987
988 #[test]
989 fn format_bytes_exactly_1gb() {
990 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
991 }
992
993 #[test]
994 fn format_bytes_negative_clamped_to_zero() {
995 assert_eq!(format_bytes(-999), "0 B");
996 }
997
998 #[test]
999 fn format_bytes_large_storage_cap() {
1000 // 500 GB (Everything tier cap)
1001 assert_eq!(format_bytes(500 * 1024 * 1024 * 1024), "500.0 GB");
1002 }
1003
1004 // ── StorageBreakdown ────────────────────────────────────────────────
1005
1006 #[test]
1007 fn storage_breakdown_default_is_all_zeros() {
1008 let sb = StorageBreakdown::default();
1009 assert_eq!(sb.audio_bytes, 0);
1010 assert_eq!(sb.cover_bytes, 0);
1011 assert_eq!(sb.download_bytes, 0);
1012 assert_eq!(sb.insertion_bytes, 0);
1013 assert_eq!(sb.video_bytes, 0);
1014 assert_eq!(sb.media_bytes, 0);
1015 assert_eq!(sb.total_bytes, 0);
1016 }
1017
1018 #[test]
1019 fn storage_breakdown_total_is_sum_of_categories() {
1020 let sb = StorageBreakdown {
1021 audio_bytes: 100,
1022 cover_bytes: 200,
1023 download_bytes: 300,
1024 insertion_bytes: 400,
1025 video_bytes: 500,
1026 media_bytes: 600,
1027 total_bytes: 100 + 200 + 300 + 400 + 500 + 600,
1028 };
1029 assert_eq!(
1030 sb.total_bytes,
1031 sb.audio_bytes + sb.cover_bytes + sb.download_bytes
1032 + sb.insertion_bytes + sb.video_bytes + sb.media_bytes,
1033 );
1034 }
1035
1036 #[test]
1037 fn storage_breakdown_single_category() {
1038 let sb = StorageBreakdown {
1039 audio_bytes: 1_000_000,
1040 total_bytes: 1_000_000,
1041 ..Default::default()
1042 };
1043 assert_eq!(sb.total_bytes, 1_000_000);
1044 assert_eq!(sb.cover_bytes, 0);
1045 }
1046
1047 // ── Boundary / cross-cutting ────────────────────────────────────────
1048
1049 #[test]
1050 fn basic_file_limit_less_than_storage_limit() {
1051 assert!(CreatorTier::Basic.max_file_bytes() < CreatorTier::Basic.max_storage_bytes());
1052 }
1053
1054 #[test]
1055 fn every_tier_file_limit_within_storage_limit() {
1056 for tier in [
1057 CreatorTier::Basic,
1058 CreatorTier::SmallFiles,
1059 CreatorTier::BigFiles,
1060 CreatorTier::Everything,
1061 ] {
1062 assert!(
1063 tier.max_file_bytes() <= tier.max_storage_bytes(),
1064 "{:?} file limit exceeds its own storage limit",
1065 tier,
1066 );
1067 }
1068 }
1069 }
1070