Skip to main content

max / makenotwork

9.1 KB · 270 lines History Blame Raw
1 //! Subscription tier, subscription, and related export models.
2
3 use chrono::{DateTime, Utc};
4 use serde::Serialize;
5 use sqlx::FromRow;
6 use uuid::Uuid;
7
8 use super::super::enums::CreatorTier;
9 use super::super::id_types::{
10 FanPlusSubscriptionId, ItemId, ProjectId, SubscriptionEventId, SubscriptionId,
11 SubscriptionTierId, UserId,
12 };
13 use super::super::validated_types::Slug;
14
15 /// A subscription tier, scoped to either a project or an item.
16 #[derive(Debug, Clone, FromRow)]
17 pub struct DbSubscriptionTier {
18 pub id: SubscriptionTierId,
19 pub project_id: Option<ProjectId>,
20 pub name: String,
21 pub description: Option<String>,
22 pub price_cents: i32,
23 pub stripe_product_id: Option<String>,
24 pub stripe_price_id: Option<String>,
25 pub sort_order: i32,
26 pub is_active: bool,
27 pub created_at: DateTime<Utc>,
28 pub updated_at: DateTime<Utc>,
29 pub item_id: Option<ItemId>,
30 }
31
32 /// Active subscription billing period.
33 #[derive(Debug, Clone)]
34 pub struct SubscriptionPeriod {
35 /// Start of the current billing period.
36 pub start: DateTime<Utc>,
37 /// End of the current billing period.
38 pub end: DateTime<Utc>,
39 }
40
41 /// A user's subscription to a project or item tier.
42 ///
43 /// **State invariant:** When `status` is `Active` or `PastDue`,
44 /// `current_period_start` and `current_period_end` are both `Some`.
45 /// When `status == Canceled`, `canceled_at` is `Some`.
46 /// When `status == Unpaid`, period fields may or may not be set.
47 /// Exactly one of `project_id` or `item_id` is `Some`.
48 #[derive(Debug, Clone, FromRow)]
49 pub struct DbSubscription {
50 pub id: SubscriptionId,
51 pub subscriber_id: UserId,
52 pub tier_id: SubscriptionTierId,
53 pub project_id: Option<ProjectId>,
54 pub stripe_subscription_id: String,
55 pub stripe_customer_id: String,
56 pub status: super::super::SubscriptionStatus,
57 /// Start of current billing period. Present when `status` is `Active` or `PastDue`.
58 pub current_period_start: Option<DateTime<Utc>>,
59 /// End of current billing period. Present when `status` is `Active` or `PastDue`.
60 pub current_period_end: Option<DateTime<Utc>>,
61 /// When the subscription was canceled. Present when `status == Canceled`.
62 pub canceled_at: Option<DateTime<Utc>>,
63 pub created_at: DateTime<Utc>,
64 pub updated_at: DateTime<Utc>,
65 pub item_id: Option<ItemId>,
66 /// When this subscription was paused due to creator suspension (None = not paused).
67 pub paused_at: Option<DateTime<Utc>>,
68 }
69
70 impl DbSubscription {
71 /// Extract the active billing period as a coherent unit.
72 ///
73 /// Returns `Some` when both period bounds are present (typically
74 /// `Active` or `PastDue` status).
75 pub fn active_period(&self) -> Option<SubscriptionPeriod> {
76 Some(SubscriptionPeriod {
77 start: self.current_period_start?,
78 end: self.current_period_end?,
79 })
80 }
81 }
82
83 /// A webhook event log entry for subscription debugging and idempotency.
84 #[derive(Debug, Clone, FromRow)]
85 #[allow(dead_code)] // Fields populated by sqlx query
86 pub struct DbSubscriptionEvent {
87 pub id: SubscriptionEventId,
88 pub subscription_id: Option<SubscriptionId>,
89 pub stripe_event_id: String,
90 pub event_type: String,
91 pub payload: serde_json::Value,
92 pub created_at: DateTime<Utc>,
93 }
94
95 /// A user subscription joined with project and tier data for the library page.
96 #[derive(Debug, Clone, FromRow)]
97 pub struct DbUserSubscriptionRow {
98 pub id: SubscriptionId,
99 pub project_id: ProjectId,
100 pub project_title: String,
101 pub project_slug: Slug,
102 pub tier_name: String,
103 pub price_cents: i32,
104 pub status: super::super::SubscriptionStatus,
105 pub current_period_end: Option<DateTime<Utc>>,
106 pub stripe_subscription_id: String,
107 }
108
109 // ── Export query models ──
110
111 /// A follower row for CSV export.
112 ///
113 /// The `email` field is only populated when the follower has a completed
114 /// purchase with `share_contact = true` and no active contact revocation.
115 #[derive(Debug, Clone, FromRow)]
116 pub struct FollowerExportRow {
117 pub username: String,
118 pub display_name: Option<String>,
119 pub target_type: super::super::FollowTargetType,
120 pub created_at: DateTime<Utc>,
121 /// Shared email (only when buyer opted in and has not revoked).
122 pub email: Option<String>,
123 }
124
125 /// A subscriber row for CSV export.
126 #[derive(Debug, Clone, FromRow)]
127 pub struct SubscriberExportRow {
128 pub username: String,
129 pub display_name: Option<String>,
130 pub tier_name: String,
131 pub status: super::super::SubscriptionStatus,
132 pub created_at: DateTime<Utc>,
133 }
134
135 /// A subscription row for the dedicated subscription CSV export.
136 #[derive(Debug, Clone, FromRow)]
137 pub struct SubscriptionExportRow {
138 pub project_title: String,
139 pub tier_name: String,
140 pub price_cents: i32,
141 pub username: String,
142 pub status: super::super::SubscriptionStatus,
143 pub current_period_start: Option<DateTime<Utc>>,
144 pub current_period_end: Option<DateTime<Utc>>,
145 pub canceled_at: Option<DateTime<Utc>>,
146 pub created_at: DateTime<Utc>,
147 }
148
149 /// A Fan+ consumer subscription.
150 #[derive(Debug, Clone, FromRow, Serialize)]
151 pub struct DbFanPlusSubscription {
152 /// Database primary key.
153 pub id: FanPlusSubscriptionId,
154 /// Subscribing user's ID.
155 pub user_id: UserId,
156 /// Stripe subscription ID (e.g. `sub_...`).
157 pub stripe_subscription_id: String,
158 /// Stripe customer ID (e.g. `cus_...`).
159 pub stripe_customer_id: String,
160 /// Subscription status (active, past_due, canceled).
161 pub status: super::super::SubscriptionStatus,
162 /// Start of current billing period.
163 pub current_period_start: Option<DateTime<Utc>>,
164 /// End of current billing period.
165 pub current_period_end: Option<DateTime<Utc>>,
166 /// When the subscription was created.
167 pub created_at: DateTime<Utc>,
168 /// When the subscription was canceled.
169 pub canceled_at: Option<DateTime<Utc>>,
170 /// Whether the subscription is scheduled to cancel at `current_period_end`.
171 /// True after the user clicks Cancel on the dashboard or in Stripe's
172 /// customer portal; cleared if they click Resume before the period ends.
173 pub cancel_at_period_end: bool,
174 }
175
176 /// A creator tier subscription (platform billing for creator features).
177 #[derive(Debug, Clone, FromRow, Serialize)]
178 pub struct DbCreatorSubscription {
179 /// Database primary key.
180 pub id: Uuid,
181 /// Subscribing creator's user ID.
182 pub user_id: UserId,
183 /// Stripe subscription ID (e.g. `sub_...`).
184 pub stripe_subscription_id: String,
185 /// Stripe customer ID (e.g. `cus_...`).
186 pub stripe_customer_id: String,
187 /// Creator tier (basic, small_files, big_files, streaming).
188 pub tier: CreatorTier,
189 /// Subscription status (active, past_due, canceled).
190 pub status: super::super::SubscriptionStatus,
191 /// Start of current billing period.
192 pub current_period_start: Option<DateTime<Utc>>,
193 /// End of current billing period.
194 pub current_period_end: Option<DateTime<Utc>>,
195 /// When the subscription was canceled.
196 pub canceled_at: Option<DateTime<Utc>>,
197 /// When the subscription was created.
198 pub created_at: DateTime<Utc>,
199 /// When post-grace enforcement was applied (items hidden).
200 pub grace_enforced_at: Option<DateTime<Utc>>,
201 }
202
203 #[cfg(test)]
204 mod tests {
205 use super::*;
206
207 fn make_subscription(
208 status: super::super::super::SubscriptionStatus,
209 period_start: Option<DateTime<Utc>>,
210 period_end: Option<DateTime<Utc>>,
211 canceled: Option<DateTime<Utc>>,
212 ) -> DbSubscription {
213 DbSubscription {
214 id: SubscriptionId::nil(),
215 subscriber_id: UserId::nil(),
216 tier_id: SubscriptionTierId::nil(),
217 project_id: Some(ProjectId::nil()),
218 stripe_subscription_id: "sub_123".to_string(),
219 stripe_customer_id: "cus_123".to_string(),
220 status,
221 current_period_start: period_start,
222 current_period_end: period_end,
223 canceled_at: canceled,
224 created_at: Utc::now(),
225 updated_at: Utc::now(),
226 item_id: None,
227 paused_at: None,
228 }
229 }
230
231 #[test]
232 fn active_period_for_active_subscription() {
233 let start = Utc::now();
234 let end = start + chrono::Duration::days(30);
235 let s = make_subscription(
236 super::super::super::SubscriptionStatus::Active,
237 Some(start),
238 Some(end),
239 None,
240 );
241 let period = s.active_period().unwrap();
242 assert_eq!(period.start, start);
243 assert_eq!(period.end, end);
244 }
245
246 #[test]
247 fn active_period_none_for_canceled() {
248 let s = make_subscription(
249 super::super::super::SubscriptionStatus::Canceled,
250 None,
251 None,
252 Some(Utc::now()),
253 );
254 assert!(s.active_period().is_none());
255 }
256
257 #[test]
258 fn active_period_for_past_due() {
259 let start = Utc::now();
260 let end = start + chrono::Duration::days(30);
261 let s = make_subscription(
262 super::super::super::SubscriptionStatus::PastDue,
263 Some(start),
264 Some(end),
265 None,
266 );
267 assert!(s.active_period().is_some());
268 }
269 }
270