Skip to main content

max / makenotwork

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