Skip to main content

max / makenotwork

15.4 KB · 394 lines History Blame Raw
1 //! User account model and Stripe connection status.
2
3 use chrono::{DateTime, Utc};
4 use serde::Serialize;
5 use sqlx::FromRow;
6
7 use super::super::id_types::*;
8 use super::super::validated_types::*;
9
10 /// Derived Stripe Connect state machine.
11 ///
12 /// Computed from `stripe_account_id`, `stripe_onboarding_complete`, and
13 /// `stripe_payouts_enabled`. The `stripe_charges_enabled` field is a
14 /// separate concern (whether the account can accept payments) and is
15 /// checked independently in checkout routes.
16 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17 pub enum StripeConnectionStatus {
18 /// No `stripe_account_id` set.
19 NotConnected,
20 /// Account connected but onboarding not yet completed.
21 Onboarding,
22 /// Onboarding complete but payouts not yet enabled by Stripe.
23 PayoutsPending,
24 /// Fully connected: onboarding complete and payouts enabled.
25 Active,
26 }
27
28 impl StripeConnectionStatus {
29 /// Human-readable label for dashboard display.
30 pub fn text(&self) -> &'static str {
31 match self {
32 Self::NotConnected => "Not connected",
33 Self::Onboarding => "Onboarding incomplete",
34 Self::PayoutsPending => "Payouts pending",
35 Self::Active => "Active",
36 }
37 }
38
39 /// CSS class for status badge rendering.
40 pub fn css_class(&self) -> &'static str {
41 match self {
42 Self::NotConnected => "inactive",
43 Self::Onboarding | Self::PayoutsPending => "pending",
44 Self::Active => "active",
45 }
46 }
47 }
48
49 /// A registered user account.
50 ///
51 /// **Stripe Connect state machine:** The four Stripe fields form a linear
52 /// progression: `NotConnected` (no account_id) → `Onboarding` (account_id
53 /// but `!onboarding_complete`) → `PayoutsPending` (`onboarding_complete`
54 /// but `!payouts_enabled`) → `Active` (`payouts_enabled`).
55 /// Use [`DbUser::stripe_connection_status()`] to get the derived state.
56 #[derive(Debug, Clone, FromRow, Serialize)]
57 pub struct DbUser {
58 /// Database primary key.
59 pub id: UserId,
60 /// Unique login handle.
61 pub username: Username,
62 /// Unique email address. Normalized (trimmed + lowercased) at write time
63 /// via [`Email::new`]; DB reads use `from_trusted`.
64 pub email: Email,
65 /// Argon2-hashed password.
66 pub password_hash: String,
67 /// Optional human-readable name shown on profile.
68 pub display_name: Option<String>,
69 /// Optional short biography.
70 pub bio: Option<String>,
71 /// URL to the user's avatar image.
72 pub avatar_url: Option<String>,
73 /// When the account was created.
74 pub created_at: DateTime<Utc>,
75 /// When the account was last modified.
76 pub updated_at: DateTime<Utc>,
77 // Stripe Connect fields (see struct-level doc for state machine)
78 /// Stripe Connect account ID (e.g. `acct_...`). None = not connected.
79 pub stripe_account_id: Option<String>,
80 /// Whether Stripe onboarding has been completed. Only meaningful when `stripe_account_id` is Some.
81 pub stripe_onboarding_complete: bool,
82 /// Whether Stripe payouts are enabled. Only meaningful when `stripe_onboarding_complete` is true.
83 pub stripe_payouts_enabled: bool,
84 /// Whether Stripe charges (payments) are enabled. Checked independently in checkout routes.
85 pub stripe_charges_enabled: bool,
86 /// Whether the creator has opted in to Stripe Tax (automatic tax calculation at checkout).
87 pub stripe_tax_enabled: bool,
88 // Email verification
89 /// Whether the user's email address has been verified.
90 pub email_verified: bool,
91 /// One-time token sent for email verification.
92 pub email_verification_token: Option<String>,
93 /// When the verification email was last sent.
94 pub email_verification_sent_at: Option<DateTime<Utc>>,
95 // Account lockout
96 /// Consecutive failed login attempts since last success.
97 pub failed_login_attempts: i32,
98 /// Account is locked until this timestamp (if set).
99 pub locked_until: Option<DateTime<Utc>>,
100 /// Timestamp of the most recent failed login attempt.
101 pub last_failed_login_at: Option<DateTime<Utc>>,
102 // Creator access
103 /// Whether this user is allowed to create projects.
104 pub can_create_projects: bool,
105 /// Whether this user's uploads skip the review queue (trusted = auto-publish).
106 pub upload_trusted: bool,
107 // Notification preferences
108 /// Whether to email the user on new device logins.
109 pub login_notification_enabled: bool,
110 // Two-factor authentication
111 /// Base32-encoded TOTP secret (set during setup, cleared on disable).
112 pub totp_secret: Option<String>,
113 /// Whether TOTP 2FA is currently active for this account.
114 pub totp_enabled: bool,
115 // Suspension
116 /// When the account was suspended (None = not suspended).
117 pub suspended_at: Option<DateTime<Utc>>,
118 /// Reason provided by admin when suspending the account.
119 pub suspension_reason: Option<String>,
120 /// User's appeal text (if they've appealed the suspension).
121 pub appeal_text: Option<String>,
122 /// When the appeal was submitted.
123 pub appeal_submitted_at: Option<DateTime<Utc>>,
124 /// Admin decision on appeal: "approved" or "denied".
125 pub appeal_decision: Option<String>,
126 /// Admin response text explaining the decision.
127 pub appeal_response: Option<String>,
128 /// When the appeal was decided.
129 pub appeal_decided_at: Option<DateTime<Utc>>,
130 // Email notification preferences
131 /// Whether to email the creator when they make a sale.
132 pub notify_sale: bool,
133 /// Whether to email the creator when they gain a follower.
134 pub notify_follower: bool,
135 /// Whether to email the user when creators they follow publish new content.
136 pub notify_release: bool,
137 /// Whether to email the repo owner about new issues and comments.
138 pub notify_issues: bool,
139 /// When the creator last sent a broadcast email (rate limiting).
140 pub last_broadcast_at: Option<DateTime<Utc>>,
141 // Onboarding email drip
142 /// Current step in the getting-started email sequence (0 = none sent, 3 = complete).
143 pub onboarding_email_step: i16,
144 /// When the last onboarding email was sent.
145 pub onboarding_email_sent_at: Option<DateTime<Utc>>,
146 /// Generation counter for ETag-based HTTP caching. Bumped on any user-visible write.
147 pub cache_generation: i64,
148 /// Denormalized creator tier (synced from creator_subscriptions on checkout/update/cancel).
149 pub creator_tier: Option<String>,
150 /// Total bytes of uploaded files (audio, covers, downloads, insertions).
151 pub storage_used_bytes: i64,
152 /// Admin-set per-file size override in bytes (None = use tier default).
153 pub max_file_override_bytes: Option<i64>,
154 /// Grandfathering deadline: SmallFiles-equivalent access until this date.
155 pub grandfathered_until: Option<DateTime<Utc>>,
156 /// Whether this creator accepts tips on their profile/project pages.
157 pub tips_enabled: bool,
158 /// Whether to email the creator when they receive a tip.
159 pub notify_tip: bool,
160 /// Whether to email the user when platform status changes (opt-in).
161 pub notify_status: bool,
162 /// When the user self-deactivated their account (None = active).
163 pub deactivated_at: Option<DateTime<Utc>>,
164 /// Whether this is an ephemeral sandbox account.
165 pub is_sandbox: bool,
166 /// When the sandbox session expires (cleanup deletes the user after this).
167 pub sandbox_expires_at: Option<DateTime<Utc>>,
168 /// When the admin permanently terminated this account (None = not terminated).
169 /// User has 30 days from this timestamp to export data before deletion.
170 pub terminated_at: Option<DateTime<Utc>>,
171 /// When content should be removed after creator self-deletion.
172 /// Buyers can still download purchased items until this date (90-day grace).
173 /// After this passes, the scheduler deletes S3 objects and the user row.
174 pub content_removal_at: Option<DateTime<Utc>>,
175 /// When the creator voluntarily paused their account (None = not paused).
176 /// Fan subscriptions are set to cancel_at_period_end (graceful expiry),
177 /// new purchases are blocked, content remains hosted indefinitely.
178 pub creator_paused_at: Option<DateTime<Utc>>,
179 /// When JWTs issued before this timestamp should be rejected (set on password change).
180 pub jwt_invalidated_at: Option<DateTime<Utc>>,
181 /// Whether this user started a creator-tier subscription during the
182 /// founder window. Sticky once true; never reset. Used by checkout to
183 /// select founder price IDs before the window closes; after close, the
184 /// `founder_locked_at` field is the source of truth for ongoing eligibility.
185 pub is_founder: bool,
186 /// When founder pricing was locked in for this user. NULL until the
187 /// window closes; set by the close-window admin sweep ONLY for users with
188 /// an active creator-tier subscription at close-time. Non-NULL means
189 /// founder prices apply to all current and future creator-tier
190 /// subscriptions on this account. NULL after the close means "lost
191 /// eligibility"; they pay sticker prices on any future subscription.
192 pub founder_locked_at: Option<DateTime<Utc>>,
193 /// Version counter folded into the personal-feed URL HMAC. Bumping it (via
194 /// the "Regenerate feed URL" dashboard action) revokes the user's existing
195 /// feed link without rotating the global signing secret. Starts at 0.
196 pub feed_key_version: i32,
197 }
198
199 impl DbUser {
200 /// Whether this user account is currently suspended.
201 pub fn is_suspended(&self) -> bool {
202 self.suspended_at.is_some()
203 }
204
205 /// Whether this user has self-deactivated their account.
206 pub fn is_deactivated(&self) -> bool {
207 self.deactivated_at.is_some()
208 }
209
210 /// Whether this creator has voluntarily paused their account.
211 pub fn is_creator_paused(&self) -> bool {
212 self.creator_paused_at.is_some()
213 }
214
215 /// Whether founder pricing is permanently locked in for this user. True
216 /// once the founder-window close sweep has stamped `founder_locked_at`.
217 pub fn is_founder_locked(&self) -> bool {
218 self.founder_locked_at.is_some()
219 }
220 }
221
222 impl DbUser {
223 /// Derive the Stripe connection status from the four Stripe fields.
224 pub fn stripe_connection_status(&self) -> StripeConnectionStatus {
225 if self.stripe_account_id.is_none() {
226 StripeConnectionStatus::NotConnected
227 } else if !self.stripe_onboarding_complete {
228 StripeConnectionStatus::Onboarding
229 } else if !self.stripe_payouts_enabled {
230 StripeConnectionStatus::PayoutsPending
231 } else {
232 StripeConnectionStatus::Active
233 }
234 }
235 }
236
237 #[cfg(test)]
238 mod tests {
239 use super::*;
240
241 #[test]
242 fn stripe_status_not_connected() {
243 let status = StripeConnectionStatus::NotConnected;
244 assert_eq!(status.text(), "Not connected");
245 assert_eq!(status.css_class(), "inactive");
246 }
247
248 #[test]
249 fn stripe_status_onboarding() {
250 let status = StripeConnectionStatus::Onboarding;
251 assert_eq!(status.text(), "Onboarding incomplete");
252 assert_eq!(status.css_class(), "pending");
253 }
254
255 #[test]
256 fn stripe_status_payouts_pending() {
257 let status = StripeConnectionStatus::PayoutsPending;
258 assert_eq!(status.text(), "Payouts pending");
259 assert_eq!(status.css_class(), "pending");
260 }
261
262 #[test]
263 fn stripe_status_active() {
264 let status = StripeConnectionStatus::Active;
265 assert_eq!(status.text(), "Active");
266 assert_eq!(status.css_class(), "active");
267 }
268
269 fn make_user(account_id: Option<&str>, onboarding: bool, payouts: bool) -> DbUser {
270 DbUser {
271 id: UserId::nil(),
272 username: Username::from_trusted("test".to_string()),
273 email: Email::from_trusted("test@example.com".to_string()),
274 password_hash: String::new(),
275 display_name: None,
276 bio: None,
277 avatar_url: None,
278 created_at: Utc::now(),
279 updated_at: Utc::now(),
280 stripe_account_id: account_id.map(|s| s.to_string()),
281 stripe_onboarding_complete: onboarding,
282 stripe_payouts_enabled: payouts,
283 stripe_charges_enabled: false,
284 stripe_tax_enabled: false,
285 email_verified: false,
286 email_verification_token: None,
287 email_verification_sent_at: None,
288 failed_login_attempts: 0,
289 locked_until: None,
290 last_failed_login_at: None,
291 can_create_projects: false,
292 upload_trusted: false,
293 login_notification_enabled: true,
294 totp_secret: None,
295 totp_enabled: false,
296 suspended_at: None,
297 suspension_reason: None,
298 appeal_text: None,
299 appeal_submitted_at: None,
300 appeal_decision: None,
301 appeal_response: None,
302 appeal_decided_at: None,
303 notify_sale: true,
304 notify_follower: true,
305 notify_release: true,
306 notify_issues: true,
307 last_broadcast_at: None,
308 onboarding_email_step: 0,
309 onboarding_email_sent_at: None,
310 cache_generation: 0,
311 creator_tier: None,
312 storage_used_bytes: 0,
313 max_file_override_bytes: None,
314 grandfathered_until: None,
315 tips_enabled: false,
316 notify_tip: true,
317 notify_status: false,
318 deactivated_at: None,
319 is_sandbox: false,
320 sandbox_expires_at: None,
321 terminated_at: None,
322 content_removal_at: None,
323 creator_paused_at: None,
324 jwt_invalidated_at: None,
325 is_founder: false,
326 founder_locked_at: None,
327 feed_key_version: 0,
328 }
329 }
330
331 #[test]
332 fn db_user_stripe_status_not_connected() {
333 let u = make_user(None, false, false);
334 assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::NotConnected);
335 }
336
337 #[test]
338 fn db_user_stripe_status_onboarding() {
339 let u = make_user(Some("acct_123"), false, false);
340 assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::Onboarding);
341 }
342
343 #[test]
344 fn db_user_stripe_status_payouts_pending() {
345 let u = make_user(Some("acct_123"), true, false);
346 assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::PayoutsPending);
347 }
348
349 #[test]
350 fn db_user_stripe_status_active() {
351 let u = make_user(Some("acct_123"), true, true);
352 assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::Active);
353 }
354
355 #[test]
356 fn is_suspended_true_when_set() {
357 let mut u = make_user(None, false, false);
358 u.suspended_at = Some(Utc::now());
359 assert!(u.is_suspended());
360 }
361
362 #[test]
363 fn is_suspended_false_when_none() {
364 let u = make_user(None, false, false);
365 assert!(!u.is_suspended());
366 }
367
368 #[test]
369 fn is_deactivated_true_when_set() {
370 let mut u = make_user(None, false, false);
371 u.deactivated_at = Some(Utc::now());
372 assert!(u.is_deactivated());
373 }
374
375 #[test]
376 fn is_deactivated_false_when_none() {
377 let u = make_user(None, false, false);
378 assert!(!u.is_deactivated());
379 }
380
381 #[test]
382 fn is_creator_paused_true_when_set() {
383 let mut u = make_user(None, false, false);
384 u.creator_paused_at = Some(Utc::now());
385 assert!(u.is_creator_paused());
386 }
387
388 #[test]
389 fn is_creator_paused_false_when_none() {
390 let u = make_user(None, false, false);
391 assert!(!u.is_creator_paused());
392 }
393 }
394