Skip to main content

max / makenotwork

Split db/creator_tiers into subscriptions + storage_quota modules The 1172-line creator_tiers.rs mixed two domains on the creator-tier table cluster: subscription lifecycle and storage-quota accounting/upload gating. Split into a creator_tiers/ directory β€” subscriptions.rs (create, Stripe apply, tier resolution, grace/founder counts), storage_quota.rs (usage counters, breakdown, batch recalc, upload/presign gating), and tests.rs β€” with mod.rs re-exporting both via `pub use`. The db::creator_tiers::* path is preserved, so all call sites are unchanged. No behavior change.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 13:02 UTC
Signed with PGP, not checked
Commit: c43108711ff20569f65368a53a7edd1da6cf0153
Parent: 1d912dc
4 files changed, +527 insertions, -487 deletions
@@ -1,177 +1,4 @@
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 - match tier {
103 - None => Ok(None),
104 - // A present-but-unparseable tier means the DB holds a tier string the
105 - // enum doesn't know (enum drift). Silently mapping that to `None` would
106 - // strip a paying creator's entitlements with no signal β€” surface it
107 - // loudly instead of swallowing it (Run 21). The `enum_drift` test and the
108 - // DB CHECK constraint make this unreachable in practice.
109 - Some(t) => match t.parse::<CreatorTier>() {
110 - Ok(parsed) => Ok(Some(parsed)),
111 - Err(_) => {
112 - tracing::error!(
113 - user_id = %user_id, tier = %t,
114 - "active creator subscription has an unrecognized tier string (enum drift)"
115 - );
116 - Err(crate::error::AppError::Internal(anyhow::anyhow!(
117 - "unrecognized creator tier '{t}' for user {user_id}"
118 - )))
119 - }
120 - },
121 - }
122 - }
123 -
124 - // Apply a Stripe-driven status and/or period update in one guarded statement.
125 - // `canceled` is terminal; reactivation runs through `create_creator_subscription`'s
126 - // `ON CONFLICT (user_id) DO UPDATE` at checkout, never through this path. Replaces
127 - // the old split status/period setters (the period half lacked the guard). See
128 - // `crate::db::subscription_writer`.
129 - crate::db::subscription_writer::define_stripe_subscription_writer!(
130 - apply_stripe_update,
131 - "creator_subscriptions",
132 - DbCreatorSubscription
133 - );
134 -
135 - /// Cancel a creator subscription (set status + canceled_at).
136 - #[tracing::instrument(skip_all)]
137 - pub async fn cancel_creator_sub(
138 - pool: &PgPool,
139 - stripe_subscription_id: &str,
140 - ) -> Result<Option<DbCreatorSubscription>> {
141 - let sub = sqlx::query_as::<_, DbCreatorSubscription>(
142 - r#"
143 - UPDATE creator_subscriptions
144 - SET status = 'canceled', canceled_at = COALESCE(canceled_at, NOW())
145 - WHERE stripe_subscription_id = $1
146 - RETURNING *
147 - "#,
148 - )
149 - .bind(stripe_subscription_id)
150 - .fetch_optional(pool)
151 - .await?;
152 -
153 - Ok(sub)
154 - }
155 -
156 - /// Sync the users.creator_tier column from the subscription status.
157 - /// Called after checkout/update/cancel to keep the denormalized column in sync.
158 - #[tracing::instrument(skip_all)]
159 - pub async fn sync_user_creator_tier(pool: &PgPool, user_id: UserId) -> Result<()> {
160 - sqlx::query(
161 - r#"
162 - UPDATE users SET creator_tier = (
163 - SELECT tier FROM creator_subscriptions
164 - WHERE user_id = $1 AND status = 'active'
165 - )
166 - WHERE id = $1
167 - "#,
168 - )
169 - .bind(user_id)
170 - .execute(pool)
171 - .await?;
172 -
173 - Ok(())
174 - }
1 + use super::*;
175 2
176 3 // ============================================================================
177 4 // Storage tracking
@@ -467,116 +294,6 @@
467 294 })
468 295 }
469 296
470 - /// Get user IDs of creators with canceled subscriptions 30+ days ago
471 - /// whose items have not yet been hidden.
472 - #[tracing::instrument(skip_all)]
473 - pub async fn get_expired_grace_creators(pool: &PgPool) -> Result<Vec<UserId>> {
474 - // Bounded batch per call. The scheduler enforces these inline on the tick
475 - // (two DB round-trips per creator), so an unbounded result set would let a
476 - // backlog stall the tick. `grace_enforced_at` is set as each creator is
477 - // processed, so successive ticks drain the rest; ORDER BY oldest-first keeps
478 - // it deterministic and starvation-free.
479 - let ids: Vec<UserId> = sqlx::query_scalar(
480 - r#"
481 - SELECT user_id FROM creator_subscriptions
482 - WHERE status = 'canceled'
483 - AND canceled_at IS NOT NULL
484 - AND canceled_at < NOW() - INTERVAL '30 days'
485 - AND grace_enforced_at IS NULL
486 - ORDER BY canceled_at ASC
487 - LIMIT 200
488 - "#,
489 - )
490 - .fetch_all(pool)
491 - .await?;
492 -
493 - Ok(ids)
494 - }
495 -
496 - /// Stamp `grace_enforced_at` for all given creators in one statement, paired with
497 - /// `items::hide_all_items_for_users` on the post-grace sweep (Perf-S4, Run 9).
498 - /// No-op on an empty slice.
499 - #[tracing::instrument(skip_all)]
500 - pub async fn mark_grace_enforced_batch(pool: &PgPool, user_ids: &[UserId]) -> Result<()> {
501 - if user_ids.is_empty() {
502 - return Ok(());
503 - }
504 - sqlx::query(
505 - "UPDATE creator_subscriptions SET grace_enforced_at = NOW() WHERE user_id = ANY($1)",
506 - )
507 - .bind(user_ids)
508 - .execute(pool)
509 - .await?;
510 -
511 - Ok(())
512 - }
513 -
514 - /// Count fully-paying creators β€” `status = 'active'` only.
515 - ///
516 - /// Excludes trialing (free trial), past_due (payment failed but not yet
517 - /// canceled), canceled-in-grace (winding down), and incomplete states.
518 - /// This is the number that goes on the runway disclosure as "paying
519 - /// creators today": revenue-bearing seats, no fudge.
520 - #[tracing::instrument(skip_all)]
521 - pub async fn count_active_paying(pool: &PgPool) -> Result<i64> {
522 - let count: (i64,) = sqlx::query_as(
523 - "SELECT COUNT(*) FROM creator_subscriptions WHERE status = 'active'",
524 - )
525 - .fetch_one(pool)
526 - .await?;
527 - Ok(count.0)
528 - }
529 -
530 - /// Count creators in a trial or 30-day cancellation grace period.
531 - ///
532 - /// These are not revenue-bearing today but represent the near-term
533 - /// pipeline: trialing seats may convert, grace seats may resubscribe
534 - /// before enforcement. Disclosed as a secondary number on the runway
535 - /// surface so the headline `count_active_paying` stays strict.
536 - #[tracing::instrument(skip_all)]
537 - pub async fn count_trialing_or_grace(pool: &PgPool) -> Result<i64> {
538 - let count: (i64,) = sqlx::query_as(
539 - r#"
540 - SELECT COUNT(*) FROM creator_subscriptions
541 - WHERE status = 'trialing'
542 - OR (
543 - status = 'canceled'
544 - AND canceled_at IS NOT NULL
545 - AND canceled_at > NOW() - INTERVAL '30 days'
546 - AND grace_enforced_at IS NULL
547 - )
548 - "#,
549 - )
550 - .fetch_one(pool)
551 - .await?;
552 - Ok(count.0)
553 - }
554 -
555 - /// Check whether a user is in the 30-day cancellation grace period.
556 - ///
557 - /// Returns `true` if the subscription is canceled but within 30 days of cancellation
558 - /// and enforcement has not yet been applied.
559 - #[tracing::instrument(skip_all)]
560 - pub async fn is_in_grace_period(pool: &PgPool, user_id: UserId) -> Result<bool> {
561 - let in_grace: bool = sqlx::query_scalar(
562 - r#"
563 - SELECT EXISTS(
564 - SELECT 1 FROM creator_subscriptions
565 - WHERE user_id = $1
566 - AND status = 'canceled'
567 - AND canceled_at IS NOT NULL
568 - AND canceled_at > NOW() - INTERVAL '30 days'
569 - AND grace_enforced_at IS NULL
570 - )
571 - "#,
572 - )
573 - .bind(user_id)
574 - .fetch_one(pool)
575 - .await?;
576 -
577 - Ok(in_grace)
578 - }
579 -
580 297 /// Creators per batch for the weekly storage recalc. Bounds each UPDATE's
581 298 /// LATERAL SUM fan-out so a large creator base can't produce one multi-minute
582 299 /// statement that trips the scheduler overrun alert; the connection is released
@@ -934,239 +651,3 @@
934 651
935 652 Ok(version_size + insertion_size)
936 653 }
937 -
938 - #[cfg(test)]
939 - mod tests {
940 - use super::*;
941 -
942 - // ── CreatorTier::label ───────────────────────────────────────────────
943 -
944 - #[test]
945 - fn label_basic() {
946 - assert_eq!(CreatorTier::Basic.label(), "Basic");
947 - }
948 -
949 - #[test]
950 - fn label_small_files() {
951 - assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
952 - }
953 -
954 - #[test]
955 - fn label_big_files() {
956 - assert_eq!(CreatorTier::BigFiles.label(), "Big Files");
957 - }
958 -
959 - #[test]
960 - fn label_everything() {
961 - assert_eq!(CreatorTier::Everything.label(), "Everything");
962 - }
963 -
964 - // ── CreatorTier price + envelope invariants ────────────────────────
965 - //
966 - // Concrete cents/bytes come from `assumptions.toml` via the installed
967 - // `TierPrices` global. Tests pin only structural invariants (positive,
968 - // monotone, per-file ≀ storage) so a future toml edit doesn't rewrite
969 - // this file. Literal-value pins live in `docs/business/assumptions.toml`
970 - // itself and are guarded by the docengine `tier_bytes ↔ tier_limits`
971 - // validator.
972 -
973 - fn all_tiers() -> [CreatorTier; 4] {
974 - [
975 - CreatorTier::Basic,
976 - CreatorTier::SmallFiles,
977 - CreatorTier::BigFiles,
978 - CreatorTier::Everything,
979 - ]
980 - }
981 -
982 - #[test]
983 - fn prices_positive_and_strictly_increasing() {
984 - crate::tier_prices::TierPrices::install_test_default();
985 - let tiers = all_tiers();
986 - assert!(tiers[0].price_cents() > 0);
987 - for pair in tiers.windows(2) {
988 - assert!(
989 - pair[0].price_cents() < pair[1].price_cents(),
990 - "{:?} should cost less than {:?}",
991 - pair[0], pair[1],
992 - );
993 - }
994 - }
995 -
996 - #[test]
997 - fn max_file_bytes_non_decreasing() {
998 - crate::tier_prices::TierPrices::install_test_default();
999 - let tiers = all_tiers();
1000 - assert!(tiers[0].max_file_bytes() > 0);
1001 - for pair in tiers.windows(2) {
1002 - assert!(
1003 - pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
1004 - "{:?} file limit should not exceed {:?}",
1005 - pair[0], pair[1],
1006 - );
1007 - }
1008 - }
1009 -
1010 - #[test]
1011 - fn max_storage_bytes_non_decreasing() {
1012 - crate::tier_prices::TierPrices::install_test_default();
1013 - let tiers = all_tiers();
1014 - assert!(tiers[0].max_storage_bytes() > 0);
1015 - for pair in tiers.windows(2) {
1016 - assert!(
1017 - pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
1018 - "{:?} storage limit should not exceed {:?}",
1019 - pair[0], pair[1],
1020 - );
1021 - }
1022 - }
1023 -
1024 - #[test]
1025 - fn top_tier_matches_big_files_envelope() {
1026 - // BigFiles and Everything share the same envelope by product design.
1027 - // (Everything differentiates on features, not caps.)
1028 - crate::tier_prices::TierPrices::install_test_default();
1029 - assert_eq!(
1030 - CreatorTier::Everything.max_file_bytes(),
1031 - CreatorTier::BigFiles.max_file_bytes(),
1032 - );
1033 - assert_eq!(
1034 - CreatorTier::Everything.max_storage_bytes(),
1035 - CreatorTier::BigFiles.max_storage_bytes(),
1036 - );
1037 - }
1038 -
1039 - // ── CreatorTier::allows_file_uploads ─────────────────────────────────
1040 -
1041 - #[test]
1042 - fn basic_tier_disallows_file_uploads() {
1043 - assert!(!CreatorTier::Basic.allows_file_uploads());
1044 - }
1045 -
1046 - #[test]
1047 - fn small_files_allows_file_uploads() {
1048 - assert!(CreatorTier::SmallFiles.allows_file_uploads());
1049 - }
1050 -
1051 - #[test]
1052 - fn big_files_allows_file_uploads() {
1053 - assert!(CreatorTier::BigFiles.allows_file_uploads());
1054 - }
1055 -
1056 - #[test]
1057 - fn everything_allows_file_uploads() {
1058 - assert!(CreatorTier::Everything.allows_file_uploads());
1059 - }
1060 -
1061 - // ── format_bytes helper ─────────────────────────────────────────────
1062 -
1063 - #[test]
1064 - fn format_bytes_zero() {
1065 - assert_eq!(format_bytes(0), "0 B");
1066 - }
1067 -
1068 - #[test]
1069 - fn format_bytes_one_byte() {
1070 - assert_eq!(format_bytes(1), "1 B");
1071 - }
1072 -
1073 - #[test]
1074 - fn format_bytes_below_kb() {
1075 - assert_eq!(format_bytes(1023), "1023 B");
1076 - }
1077 -
1078 - #[test]
1079 - fn format_bytes_exactly_1kb() {
1080 - assert_eq!(format_bytes(1024), "1.0 KB");
1081 - }
1082 -
1083 - #[test]
1084 - fn format_bytes_exactly_1mb() {
1085 - assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1086 - }
1087 -
1088 - #[test]
1089 - fn format_bytes_exactly_1gb() {
1090 - assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1091 - }
1092 -
1093 - #[test]
1094 - fn format_bytes_negative_clamped_to_zero() {
1095 - assert_eq!(format_bytes(-999), "0 B");
1096 - }
1097 -
1098 - #[test]
1099 - fn format_bytes_large_storage_cap() {
1100 - // 500 GB (Everything tier cap)
1101 - assert_eq!(format_bytes(500 * 1024 * 1024 * 1024), "500.0 GB");
1102 - }
1103 -
1104 - // ── StorageBreakdown ────────────────────────────────────────────────
1105 -
1106 - #[test]
1107 - fn storage_breakdown_default_is_all_zeros() {
1108 - let sb = StorageBreakdown::default();
1109 - assert_eq!(sb.audio_bytes, 0);
1110 - assert_eq!(sb.cover_bytes, 0);
1111 - assert_eq!(sb.download_bytes, 0);
1112 - assert_eq!(sb.insertion_bytes, 0);
1113 - assert_eq!(sb.video_bytes, 0);
1114 - assert_eq!(sb.media_bytes, 0);
1115 - assert_eq!(sb.total_bytes, 0);
1116 - }
1117 -
1118 - #[test]
1119 - fn storage_breakdown_total_is_sum_of_categories() {
1120 - let sb = StorageBreakdown {
1121 - audio_bytes: 100,
1122 - cover_bytes: 200,
1123 - download_bytes: 300,
1124 - insertion_bytes: 400,
1125 - video_bytes: 500,
1126 - media_bytes: 600,
1127 - gallery_bytes: 700,
1128 - total_bytes: 100 + 200 + 300 + 400 + 500 + 600 + 700,
1129 - };
1130 - assert_eq!(
1131 - sb.total_bytes,
1132 - sb.audio_bytes + sb.cover_bytes + sb.download_bytes
1133 - + sb.insertion_bytes + sb.video_bytes + sb.media_bytes + sb.gallery_bytes,
1134 - );
1135 - }
1136 -
1137 - #[test]
1138 - fn storage_breakdown_single_category() {
1139 - let sb = StorageBreakdown {
Lines truncated
@@ -1,0 +1,20 @@
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 + mod storage_quota;
14 + mod subscriptions;
15 +
16 + pub use storage_quota::*;
17 + pub use subscriptions::*;
18 +
19 + #[cfg(test)]
20 + mod tests;
@@ -1,0 +1,274 @@
1 + use super::*;
2 +
3 + /// Create or reactivate a creator tier subscription record.
4 + ///
5 + /// Uses ON CONFLICT DO UPDATE on the user_id unique index to handle
6 + /// both duplicate webhooks and re-subscription after cancellation.
7 + /// Returns `None` if the row already existed with the same stripe_subscription_id
8 + /// (duplicate webhook), `Some` if this was a fresh insert or a re-subscription
9 + /// with a different subscription ID.
10 + #[tracing::instrument(skip_all)]
11 + pub async fn create_creator_subscription<'e>(
12 + executor: impl sqlx::PgExecutor<'e>,
13 + user_id: UserId,
14 + stripe_subscription_id: &str,
15 + stripe_customer_id: &str,
16 + tier: CreatorTier,
17 + ) -> Result<Option<DbCreatorSubscription>> {
18 + // Use WHERE clause on the DO UPDATE to only update if the subscription_id
19 + // is different (new subscription) or status is not already active.
20 + // When the WHERE fails, DO UPDATE becomes a no-op and RETURNING yields no row.
21 + let sub = sqlx::query_as::<_, DbCreatorSubscription>(
22 + r#"
23 + INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier)
24 + VALUES ($1, $2, $3, $4)
25 + ON CONFLICT (user_id) DO UPDATE
26 + SET stripe_subscription_id = EXCLUDED.stripe_subscription_id,
27 + stripe_customer_id = EXCLUDED.stripe_customer_id,
28 + tier = EXCLUDED.tier,
29 + status = 'active',
30 + canceled_at = NULL,
31 + grace_enforced_at = NULL
32 + WHERE creator_subscriptions.stripe_subscription_id != EXCLUDED.stripe_subscription_id
33 + OR creator_subscriptions.status != 'active'
34 + RETURNING *
35 + "#,
36 + )
37 + .bind(user_id)
38 + .bind(stripe_subscription_id)
39 + .bind(stripe_customer_id)
40 + .bind(tier)
41 + .fetch_optional(executor)
42 + .await?;
43 +
44 + Ok(sub)
45 + }
46 +
47 + /// Look up a creator subscription by its Stripe subscription ID.
48 + #[tracing::instrument(skip_all)]
49 + pub async fn get_creator_sub_by_stripe_id(
50 + pool: &PgPool,
51 + stripe_subscription_id: &str,
52 + ) -> Result<Option<DbCreatorSubscription>> {
53 + let sub = sqlx::query_as::<_, DbCreatorSubscription>(
54 + "SELECT * FROM creator_subscriptions WHERE stripe_subscription_id = $1",
55 + )
56 + .bind(stripe_subscription_id)
57 + .fetch_optional(pool)
58 + .await?;
59 +
60 + Ok(sub)
61 + }
62 +
63 + /// Get a user's creator subscription (any status).
64 + #[tracing::instrument(skip_all)]
65 + pub async fn get_creator_sub_by_user(
66 + pool: &PgPool,
67 + user_id: UserId,
68 + ) -> Result<Option<DbCreatorSubscription>> {
69 + let sub = sqlx::query_as::<_, DbCreatorSubscription>(
70 + "SELECT * FROM creator_subscriptions WHERE user_id = $1",
71 + )
72 + .bind(user_id)
73 + .fetch_optional(pool)
74 + .await?;
75 +
76 + Ok(sub)
77 + }
78 +
79 + /// Get the active creator tier for a user (None if no active subscription).
80 + #[tracing::instrument(skip_all)]
81 + pub async fn get_active_creator_tier(
82 + pool: &PgPool,
83 + user_id: UserId,
84 + ) -> Result<Option<CreatorTier>> {
85 + let tier = sqlx::query_scalar::<_, String>(
86 + "SELECT tier FROM creator_subscriptions WHERE user_id = $1 AND status = 'active'",
87 + )
88 + .bind(user_id)
89 + .fetch_optional(pool)
90 + .await?;
91 +
92 + match tier {
93 + None => Ok(None),
94 + // A present-but-unparseable tier means the DB holds a tier string the
95 + // enum doesn't know (enum drift). Silently mapping that to `None` would
96 + // strip a paying creator's entitlements with no signal β€” surface it
97 + // loudly instead of swallowing it (Run 21). The `enum_drift` test and the
98 + // DB CHECK constraint make this unreachable in practice.
99 + Some(t) => match t.parse::<CreatorTier>() {
100 + Ok(parsed) => Ok(Some(parsed)),
101 + Err(_) => {
102 + tracing::error!(
103 + user_id = %user_id, tier = %t,
104 + "active creator subscription has an unrecognized tier string (enum drift)"
105 + );
106 + Err(crate::error::AppError::Internal(anyhow::anyhow!(
107 + "unrecognized creator tier '{t}' for user {user_id}"
108 + )))
109 + }
110 + },
111 + }
112 + }
113 +
114 + // Apply a Stripe-driven status and/or period update in one guarded statement.
115 + // `canceled` is terminal; reactivation runs through `create_creator_subscription`'s
116 + // `ON CONFLICT (user_id) DO UPDATE` at checkout, never through this path. Replaces
117 + // the old split status/period setters (the period half lacked the guard). See
118 + // `crate::db::subscription_writer`.
119 + crate::db::subscription_writer::define_stripe_subscription_writer!(
120 + apply_stripe_update,
121 + "creator_subscriptions",
122 + DbCreatorSubscription
123 + );
124 +
125 + /// Cancel a creator subscription (set status + canceled_at).
126 + #[tracing::instrument(skip_all)]
127 + pub async fn cancel_creator_sub(
128 + pool: &PgPool,
129 + stripe_subscription_id: &str,
130 + ) -> Result<Option<DbCreatorSubscription>> {
131 + let sub = sqlx::query_as::<_, DbCreatorSubscription>(
132 + r#"
133 + UPDATE creator_subscriptions
134 + SET status = 'canceled', canceled_at = COALESCE(canceled_at, NOW())
135 + WHERE stripe_subscription_id = $1
136 + RETURNING *
137 + "#,
138 + )
139 + .bind(stripe_subscription_id)
140 + .fetch_optional(pool)
141 + .await?;
142 +
143 + Ok(sub)
144 + }
145 +
146 + /// Sync the users.creator_tier column from the subscription status.
147 + /// Called after checkout/update/cancel to keep the denormalized column in sync.
148 + #[tracing::instrument(skip_all)]
149 + pub async fn sync_user_creator_tier(pool: &PgPool, user_id: UserId) -> Result<()> {
150 + sqlx::query(
151 + r#"
152 + UPDATE users SET creator_tier = (
153 + SELECT tier FROM creator_subscriptions
154 + WHERE user_id = $1 AND status = 'active'
155 + )
156 + WHERE id = $1
157 + "#,
158 + )
159 + .bind(user_id)
160 + .execute(pool)
161 + .await?;
162 +
163 + Ok(())
164 + }
165 +
166 + /// Get user IDs of creators with canceled subscriptions 30+ days ago
167 + /// whose items have not yet been hidden.
168 + #[tracing::instrument(skip_all)]
169 + pub async fn get_expired_grace_creators(pool: &PgPool) -> Result<Vec<UserId>> {
170 + // Bounded batch per call. The scheduler enforces these inline on the tick
171 + // (two DB round-trips per creator), so an unbounded result set would let a
172 + // backlog stall the tick. `grace_enforced_at` is set as each creator is
173 + // processed, so successive ticks drain the rest; ORDER BY oldest-first keeps
174 + // it deterministic and starvation-free.
175 + let ids: Vec<UserId> = sqlx::query_scalar(
176 + r#"
177 + SELECT user_id FROM creator_subscriptions
178 + WHERE status = 'canceled'
179 + AND canceled_at IS NOT NULL
180 + AND canceled_at < NOW() - INTERVAL '30 days'
181 + AND grace_enforced_at IS NULL
182 + ORDER BY canceled_at ASC
183 + LIMIT 200
184 + "#,
185 + )
186 + .fetch_all(pool)
187 + .await?;
188 +
189 + Ok(ids)
190 + }
191 +
192 + /// Stamp `grace_enforced_at` for all given creators in one statement, paired with
193 + /// `items::hide_all_items_for_users` on the post-grace sweep (Perf-S4, Run 9).
194 + /// No-op on an empty slice.
195 + #[tracing::instrument(skip_all)]
196 + pub async fn mark_grace_enforced_batch(pool: &PgPool, user_ids: &[UserId]) -> Result<()> {
197 + if user_ids.is_empty() {
198 + return Ok(());
199 + }
200 + sqlx::query(
201 + "UPDATE creator_subscriptions SET grace_enforced_at = NOW() WHERE user_id = ANY($1)",
202 + )
203 + .bind(user_ids)
204 + .execute(pool)
205 + .await?;
206 +
207 + Ok(())
208 + }
209 +
210 + /// Count fully-paying creators β€” `status = 'active'` only.
211 + ///
212 + /// Excludes trialing (free trial), past_due (payment failed but not yet
213 + /// canceled), canceled-in-grace (winding down), and incomplete states.
214 + /// This is the number that goes on the runway disclosure as "paying
215 + /// creators today": revenue-bearing seats, no fudge.
216 + #[tracing::instrument(skip_all)]
217 + pub async fn count_active_paying(pool: &PgPool) -> Result<i64> {
218 + let count: (i64,) = sqlx::query_as(
219 + "SELECT COUNT(*) FROM creator_subscriptions WHERE status = 'active'",
220 + )
221 + .fetch_one(pool)
222 + .await?;
223 + Ok(count.0)
224 + }
225 +
226 + /// Count creators in a trial or 30-day cancellation grace period.
227 + ///
228 + /// These are not revenue-bearing today but represent the near-term
229 + /// pipeline: trialing seats may convert, grace seats may resubscribe
230 + /// before enforcement. Disclosed as a secondary number on the runway
231 + /// surface so the headline `count_active_paying` stays strict.
232 + #[tracing::instrument(skip_all)]
233 + pub async fn count_trialing_or_grace(pool: &PgPool) -> Result<i64> {
234 + let count: (i64,) = sqlx::query_as(
235 + r#"
236 + SELECT COUNT(*) FROM creator_subscriptions
237 + WHERE status = 'trialing'
238 + OR (
239 + status = 'canceled'
240 + AND canceled_at IS NOT NULL
241 + AND canceled_at > NOW() - INTERVAL '30 days'
242 + AND grace_enforced_at IS NULL
243 + )
244 + "#,
245 + )
246 + .fetch_one(pool)
247 + .await?;
248 + Ok(count.0)
249 + }
250 +
251 + /// Check whether a user is in the 30-day cancellation grace period.
252 + ///
253 + /// Returns `true` if the subscription is canceled but within 30 days of cancellation
254 + /// and enforcement has not yet been applied.
255 + #[tracing::instrument(skip_all)]
256 + pub async fn is_in_grace_period(pool: &PgPool, user_id: UserId) -> Result<bool> {
257 + let in_grace: bool = sqlx::query_scalar(
258 + r#"
259 + SELECT EXISTS(
260 + SELECT 1 FROM creator_subscriptions
261 + WHERE user_id = $1
262 + AND status = 'canceled'
263 + AND canceled_at IS NOT NULL
264 + AND canceled_at > NOW() - INTERVAL '30 days'
265 + AND grace_enforced_at IS NULL
266 + )
267 + "#,
268 + )
269 + .bind(user_id)
270 + .fetch_one(pool)
271 + .await?;
272 +
273 + Ok(in_grace)
274 + }
@@ -1,0 +1,232 @@
1 + use super::*;
2 +
3 + // ── CreatorTier::label ───────────────────────────────────────────────
4 +
5 + #[test]
6 + fn label_basic() {
7 + assert_eq!(CreatorTier::Basic.label(), "Basic");
8 + }
9 +
10 + #[test]
11 + fn label_small_files() {
12 + assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
13 + }
14 +
15 + #[test]
16 + fn label_big_files() {
17 + assert_eq!(CreatorTier::BigFiles.label(), "Big Files");
18 + }
19 +
20 + #[test]
21 + fn label_everything() {
22 + assert_eq!(CreatorTier::Everything.label(), "Everything");
23 + }
24 +
25 + // ── CreatorTier price + envelope invariants ────────────────────────
26 + //
27 + // Concrete cents/bytes come from `assumptions.toml` via the installed
28 + // `TierPrices` global. Tests pin only structural invariants (positive,
29 + // monotone, per-file ≀ storage) so a future toml edit doesn't rewrite
30 + // this file. Literal-value pins live in `docs/business/assumptions.toml`
31 + // itself and are guarded by the docengine `tier_bytes ↔ tier_limits`
32 + // validator.
33 +
34 + fn all_tiers() -> [CreatorTier; 4] {
35 + [
36 + CreatorTier::Basic,
37 + CreatorTier::SmallFiles,
38 + CreatorTier::BigFiles,
39 + CreatorTier::Everything,
40 + ]
41 + }
42 +
43 + #[test]
44 + fn prices_positive_and_strictly_increasing() {
45 + crate::tier_prices::TierPrices::install_test_default();
46 + let tiers = all_tiers();
47 + assert!(tiers[0].price_cents() > 0);
48 + for pair in tiers.windows(2) {
49 + assert!(
50 + pair[0].price_cents() < pair[1].price_cents(),
51 + "{:?} should cost less than {:?}",
52 + pair[0], pair[1],
53 + );
54 + }
55 + }
56 +
57 + #[test]
58 + fn max_file_bytes_non_decreasing() {
59 + crate::tier_prices::TierPrices::install_test_default();
60 + let tiers = all_tiers();
61 + assert!(tiers[0].max_file_bytes() > 0);
62 + for pair in tiers.windows(2) {
63 + assert!(
64 + pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
65 + "{:?} file limit should not exceed {:?}",
66 + pair[0], pair[1],
67 + );
68 + }
69 + }
70 +
71 + #[test]
72 + fn max_storage_bytes_non_decreasing() {
73 + crate::tier_prices::TierPrices::install_test_default();
74 + let tiers = all_tiers();
75 + assert!(tiers[0].max_storage_bytes() > 0);
76 + for pair in tiers.windows(2) {
77 + assert!(
78 + pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
79 + "{:?} storage limit should not exceed {:?}",
80 + pair[0], pair[1],
81 + );
82 + }
83 + }
84 +
85 + #[test]
86 + fn top_tier_matches_big_files_envelope() {
87 + // BigFiles and Everything share the same envelope by product design.
88 + // (Everything differentiates on features, not caps.)
89 + crate::tier_prices::TierPrices::install_test_default();
90 + assert_eq!(
91 + CreatorTier::Everything.max_file_bytes(),
92 + CreatorTier::BigFiles.max_file_bytes(),
93 + );
94 + assert_eq!(
95 + CreatorTier::Everything.max_storage_bytes(),
96 + CreatorTier::BigFiles.max_storage_bytes(),
97 + );
98 + }
99 +
100 + // ── CreatorTier::allows_file_uploads ─────────────────────────────────
101 +
102 + #[test]
103 + fn basic_tier_disallows_file_uploads() {
104 + assert!(!CreatorTier::Basic.allows_file_uploads());
105 + }
106 +
107 + #[test]
108 + fn small_files_allows_file_uploads() {
109 + assert!(CreatorTier::SmallFiles.allows_file_uploads());
110 + }
111 +
112 + #[test]
113 + fn big_files_allows_file_uploads() {
114 + assert!(CreatorTier::BigFiles.allows_file_uploads());
115 + }
116 +
117 + #[test]
118 + fn everything_allows_file_uploads() {
119 + assert!(CreatorTier::Everything.allows_file_uploads());
120 + }
121 +
122 + // ── format_bytes helper ─────────────────────────────────────────────
123 +
124 + #[test]
125 + fn format_bytes_zero() {
126 + assert_eq!(format_bytes(0), "0 B");
127 + }
128 +
129 + #[test]
130 + fn format_bytes_one_byte() {
131 + assert_eq!(format_bytes(1), "1 B");
132 + }
133 +
134 + #[test]
135 + fn format_bytes_below_kb() {
136 + assert_eq!(format_bytes(1023), "1023 B");
137 + }
138 +
139 + #[test]
140 + fn format_bytes_exactly_1kb() {
141 + assert_eq!(format_bytes(1024), "1.0 KB");
142 + }
143 +
144 + #[test]
145 + fn format_bytes_exactly_1mb() {
146 + assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
147 + }
148 +
149 + #[test]
150 + fn format_bytes_exactly_1gb() {
151 + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
152 + }
153 +
154 + #[test]
155 + fn format_bytes_negative_clamped_to_zero() {
156 + assert_eq!(format_bytes(-999), "0 B");
157 + }
158 +
159 + #[test]
160 + fn format_bytes_large_storage_cap() {
161 + // 500 GB (Everything tier cap)
162 + assert_eq!(format_bytes(500 * 1024 * 1024 * 1024), "500.0 GB");
163 + }
164 +
165 + // ── StorageBreakdown ────────────────────────────────────────────────
166 +
167 + #[test]
168 + fn storage_breakdown_default_is_all_zeros() {
169 + let sb = StorageBreakdown::default();
170 + assert_eq!(sb.audio_bytes, 0);
171 + assert_eq!(sb.cover_bytes, 0);
172 + assert_eq!(sb.download_bytes, 0);
173 + assert_eq!(sb.insertion_bytes, 0);
174 + assert_eq!(sb.video_bytes, 0);
175 + assert_eq!(sb.media_bytes, 0);
176 + assert_eq!(sb.total_bytes, 0);
177 + }
178 +
179 + #[test]
180 + fn storage_breakdown_total_is_sum_of_categories() {
181 + let sb = StorageBreakdown {
182 + audio_bytes: 100,
183 + cover_bytes: 200,
184 + download_bytes: 300,
185 + insertion_bytes: 400,
186 + video_bytes: 500,
187 + media_bytes: 600,
188 + gallery_bytes: 700,
189 + total_bytes: 100 + 200 + 300 + 400 + 500 + 600 + 700,
190 + };
191 + assert_eq!(
192 + sb.total_bytes,
193 + sb.audio_bytes + sb.cover_bytes + sb.download_bytes
194 + + sb.insertion_bytes + sb.video_bytes + sb.media_bytes + sb.gallery_bytes,
195 + );
196 + }
197 +
198 + #[test]
199 + fn storage_breakdown_single_category() {
200 + let sb = StorageBreakdown {
201 + audio_bytes: 1_000_000,
202 + total_bytes: 1_000_000,
203 + ..Default::default()
204 + };
205 + assert_eq!(sb.total_bytes, 1_000_000);
206 + assert_eq!(sb.cover_bytes, 0);
207 + }
208 +
209 + // ── Boundary / cross-cutting ────────────────────────────────────────
210 +
211 + #[test]
212 + fn basic_file_limit_less_than_storage_limit() {
213 + crate::tier_prices::TierPrices::install_test_default();
214 + assert!(CreatorTier::Basic.max_file_bytes() < CreatorTier::Basic.max_storage_bytes());
215 + }
216 +
217 + #[test]
218 + fn every_tier_file_limit_within_storage_limit() {
219 + crate::tier_prices::TierPrices::install_test_default();
220 + for tier in [
221 + CreatorTier::Basic,
222 + CreatorTier::SmallFiles,
223 + CreatorTier::BigFiles,
224 + CreatorTier::Everything,
225 + ] {
226 + assert!(
227 + tier.max_file_bytes() <= tier.max_storage_bytes(),
228 + "{:?} file limit exceeds its own storage limit",
229 + tier,
230 + );
231 + }
232 + }