Skip to main content

max / makenotwork

9.4 KB · 274 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 /// The creator's settlement currency. Joined rather than taken from the
108 /// viewer, because this is one buyer's subscriptions across different
109 /// creators and can genuinely hold more than one currency at a time.
110 pub settlement_currency: crate::currency::SettlementCurrency,
111 }
112
113 // ── Export query models ──
114
115 /// A follower row for CSV export.
116 ///
117 /// The `email` field is only populated when the follower has a completed
118 /// purchase with `share_contact = true` and no active contact revocation.
119 #[derive(Debug, Clone, FromRow)]
120 pub struct FollowerExportRow {
121 pub username: String,
122 pub display_name: Option<String>,
123 pub target_type: super::super::FollowTargetType,
124 pub created_at: DateTime<Utc>,
125 /// Shared email (only when buyer opted in and has not revoked).
126 pub email: Option<String>,
127 }
128
129 /// A subscriber row for CSV export.
130 #[derive(Debug, Clone, FromRow)]
131 pub struct SubscriberExportRow {
132 pub username: String,
133 pub display_name: Option<String>,
134 pub tier_name: String,
135 pub status: super::super::SubscriptionStatus,
136 pub created_at: DateTime<Utc>,
137 }
138
139 /// A subscription row for the dedicated subscription CSV export.
140 #[derive(Debug, Clone, FromRow)]
141 pub struct SubscriptionExportRow {
142 pub project_title: String,
143 pub tier_name: String,
144 pub price_cents: i32,
145 pub username: String,
146 pub status: super::super::SubscriptionStatus,
147 pub current_period_start: Option<DateTime<Utc>>,
148 pub current_period_end: Option<DateTime<Utc>>,
149 pub canceled_at: Option<DateTime<Utc>>,
150 pub created_at: DateTime<Utc>,
151 }
152
153 /// A Fan+ consumer subscription.
154 #[derive(Debug, Clone, FromRow, Serialize)]
155 pub struct DbFanPlusSubscription {
156 /// Database primary key.
157 pub id: FanPlusSubscriptionId,
158 /// Subscribing user's ID.
159 pub user_id: UserId,
160 /// Stripe subscription ID (e.g. `sub_...`).
161 pub stripe_subscription_id: String,
162 /// Stripe customer ID (e.g. `cus_...`).
163 pub stripe_customer_id: String,
164 /// Subscription status (active, past_due, canceled).
165 pub status: super::super::SubscriptionStatus,
166 /// Start of current billing period.
167 pub current_period_start: Option<DateTime<Utc>>,
168 /// End of current billing period.
169 pub current_period_end: Option<DateTime<Utc>>,
170 /// When the subscription was created.
171 pub created_at: DateTime<Utc>,
172 /// When the subscription was canceled.
173 pub canceled_at: Option<DateTime<Utc>>,
174 /// Whether the subscription is scheduled to cancel at `current_period_end`.
175 /// True after the user clicks Cancel on the dashboard or in Stripe's
176 /// customer portal; cleared if they click Resume before the period ends.
177 pub cancel_at_period_end: bool,
178 }
179
180 /// A creator tier subscription (platform billing for creator features).
181 #[derive(Debug, Clone, FromRow, Serialize)]
182 pub struct DbCreatorSubscription {
183 /// Database primary key.
184 pub id: Uuid,
185 /// Subscribing creator's user ID.
186 pub user_id: UserId,
187 /// Stripe subscription ID (e.g. `sub_...`).
188 pub stripe_subscription_id: String,
189 /// Stripe customer ID (e.g. `cus_...`).
190 pub stripe_customer_id: String,
191 /// Creator tier (basic, small_files, big_files, streaming).
192 pub tier: CreatorTier,
193 /// Subscription status (active, past_due, canceled).
194 pub status: super::super::SubscriptionStatus,
195 /// Start of current billing period.
196 pub current_period_start: Option<DateTime<Utc>>,
197 /// End of current billing period.
198 pub current_period_end: Option<DateTime<Utc>>,
199 /// When the subscription was canceled.
200 pub canceled_at: Option<DateTime<Utc>>,
201 /// When the subscription was created.
202 pub created_at: DateTime<Utc>,
203 /// When post-grace enforcement was applied (items hidden).
204 pub grace_enforced_at: Option<DateTime<Utc>>,
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210
211 fn make_subscription(
212 status: super::super::super::SubscriptionStatus,
213 period_start: Option<DateTime<Utc>>,
214 period_end: Option<DateTime<Utc>>,
215 canceled: Option<DateTime<Utc>>,
216 ) -> DbSubscription {
217 DbSubscription {
218 id: SubscriptionId::nil(),
219 subscriber_id: UserId::nil(),
220 tier_id: SubscriptionTierId::nil(),
221 project_id: Some(ProjectId::nil()),
222 stripe_subscription_id: "sub_123".to_string(),
223 stripe_customer_id: "cus_123".to_string(),
224 status,
225 current_period_start: period_start,
226 current_period_end: period_end,
227 canceled_at: canceled,
228 created_at: Utc::now(),
229 updated_at: Utc::now(),
230 item_id: None,
231 paused_at: None,
232 }
233 }
234
235 #[test]
236 fn active_period_for_active_subscription() {
237 let start = Utc::now();
238 let end = start + chrono::Duration::days(30);
239 let s = make_subscription(
240 super::super::super::SubscriptionStatus::Active,
241 Some(start),
242 Some(end),
243 None,
244 );
245 let period = s.active_period().unwrap();
246 assert_eq!(period.start, start);
247 assert_eq!(period.end, end);
248 }
249
250 #[test]
251 fn active_period_none_for_canceled() {
252 let s = make_subscription(
253 super::super::super::SubscriptionStatus::Canceled,
254 None,
255 None,
256 Some(Utc::now()),
257 );
258 assert!(s.active_period().is_none());
259 }
260
261 #[test]
262 fn active_period_for_past_due() {
263 let start = Utc::now();
264 let end = start + chrono::Duration::days(30);
265 let s = make_subscription(
266 super::super::super::SubscriptionStatus::PastDue,
267 Some(start),
268 Some(end),
269 None,
270 );
271 assert!(s.active_period().is_some());
272 }
273 }
274