Skip to main content

max / makenotwork

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