Skip to main content

max / makenotwork

16.9 KB · 427 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::UserId;
8 use super::super::validated_types::{Email, StripeAccountId, Username};
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<StripeAccountId>,
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 /// When SyncKit JWTs issued before this timestamp should be rejected. Set on
182 /// a sync-device removal so the removed device's token dies immediately.
183 /// Separate from `jwt_invalidated_at` so a sync revocation does not log the
184 /// user out of the website.
185 pub sync_jwt_invalidated_at: Option<DateTime<Utc>>,
186 /// Whether this user started a creator-tier subscription during the
187 /// founder window. Sticky once true; never reset. Used by checkout to
188 /// select founder price IDs before the window closes; after close, the
189 /// `founder_locked_at` field is the source of truth for ongoing eligibility.
190 pub is_founder: bool,
191 /// When founder pricing was locked in for this user. NULL until the
192 /// window closes; set by the close-window admin sweep ONLY for users with
193 /// an active creator-tier subscription at close-time. Non-NULL means
194 /// founder prices apply to all current and future creator-tier
195 /// subscriptions on this account. NULL after the close means "lost
196 /// eligibility"; they pay sticker prices on any future subscription.
197 pub founder_locked_at: Option<DateTime<Utc>>,
198 /// Version counter folded into the personal-feed URL HMAC. Bumping it (via
199 /// the "Regenerate feed URL" dashboard action) revokes the user's existing
200 /// feed link without rotating the global signing secret. Starts at 0.
201 pub feed_key_version: i32,
202 /// Chosen built-in theme id for this creator's public profile page. `None` =
203 /// the platform default. References a bundled theme; see `crate::theming`.
204 pub theme_id: Option<String>,
205 /// Creator-authored profile-page HTML (original source, pre-sanitization).
206 /// Empty string = no customization, render the default profile. Served from
207 /// `u.makenot.work`; see `crate::custom_pages`.
208 pub custom_html: String,
209 /// Creator-authored profile-page CSS (original source, pre-sanitization).
210 pub custom_css: String,
211 /// When the custom page was last saved (cache-key + moderation review).
212 pub custom_pages_updated_at: Option<DateTime<Utc>>,
213 /// Moderation kill switch: while true the editor is read-only.
214 pub custom_pages_locked: bool,
215 }
216
217 impl DbUser {
218 /// Whether this user account is currently suspended.
219 pub fn is_suspended(&self) -> bool {
220 self.suspended_at.is_some()
221 }
222
223 /// Whether this user has self-deactivated their account.
224 pub fn is_deactivated(&self) -> bool {
225 self.deactivated_at.is_some()
226 }
227
228 /// Whether this creator has voluntarily paused their account.
229 pub fn is_creator_paused(&self) -> bool {
230 self.creator_paused_at.is_some()
231 }
232
233 /// Whether founder pricing is permanently locked in for this user. True
234 /// once the founder-window close sweep has stamped `founder_locked_at`.
235 pub fn is_founder_locked(&self) -> bool {
236 self.founder_locked_at.is_some()
237 }
238 }
239
240 impl DbUser {
241 /// Derive the Stripe connection status from the four Stripe fields.
242 pub fn stripe_connection_status(&self) -> StripeConnectionStatus {
243 if self.stripe_account_id.is_none() {
244 StripeConnectionStatus::NotConnected
245 } else if !self.stripe_onboarding_complete {
246 StripeConnectionStatus::Onboarding
247 } else if !self.stripe_payouts_enabled {
248 StripeConnectionStatus::PayoutsPending
249 } else {
250 StripeConnectionStatus::Active
251 }
252 }
253 }
254
255 #[cfg(test)]
256 mod tests {
257 use super::*;
258
259 #[test]
260 fn stripe_status_not_connected() {
261 let status = StripeConnectionStatus::NotConnected;
262 assert_eq!(status.text(), "Not connected");
263 assert_eq!(status.css_class(), "inactive");
264 }
265
266 #[test]
267 fn stripe_status_onboarding() {
268 let status = StripeConnectionStatus::Onboarding;
269 assert_eq!(status.text(), "Onboarding incomplete");
270 assert_eq!(status.css_class(), "pending");
271 }
272
273 #[test]
274 fn stripe_status_payouts_pending() {
275 let status = StripeConnectionStatus::PayoutsPending;
276 assert_eq!(status.text(), "Payouts pending");
277 assert_eq!(status.css_class(), "pending");
278 }
279
280 #[test]
281 fn stripe_status_active() {
282 let status = StripeConnectionStatus::Active;
283 assert_eq!(status.text(), "Active");
284 assert_eq!(status.css_class(), "active");
285 }
286
287 fn make_user(account_id: Option<&str>, onboarding: bool, payouts: bool) -> DbUser {
288 DbUser {
289 id: UserId::nil(),
290 username: Username::from_trusted("test".to_string()),
291 email: Email::from_trusted("test@example.com".to_string()),
292 password_hash: String::new(),
293 display_name: None,
294 bio: None,
295 avatar_url: None,
296 theme_id: None,
297 custom_html: String::new(),
298 custom_css: String::new(),
299 custom_pages_updated_at: None,
300 custom_pages_locked: false,
301 created_at: Utc::now(),
302 updated_at: Utc::now(),
303 stripe_account_id: account_id.map(|s| StripeAccountId::from_trusted(s.to_string())),
304 stripe_onboarding_complete: onboarding,
305 stripe_payouts_enabled: payouts,
306 stripe_charges_enabled: false,
307 stripe_tax_enabled: false,
308 email_verified: false,
309 email_verification_token: None,
310 email_verification_sent_at: None,
311 failed_login_attempts: 0,
312 locked_until: None,
313 last_failed_login_at: None,
314 can_create_projects: false,
315 upload_trusted: false,
316 login_notification_enabled: true,
317 totp_secret: None,
318 totp_enabled: false,
319 suspended_at: None,
320 suspension_reason: None,
321 appeal_text: None,
322 appeal_submitted_at: None,
323 appeal_decision: None,
324 appeal_response: None,
325 appeal_decided_at: None,
326 notify_sale: true,
327 notify_follower: true,
328 notify_release: true,
329 notify_issues: true,
330 last_broadcast_at: None,
331 onboarding_email_step: 0,
332 onboarding_email_sent_at: None,
333 cache_generation: 0,
334 creator_tier: None,
335 storage_used_bytes: 0,
336 max_file_override_bytes: None,
337 grandfathered_until: None,
338 tips_enabled: false,
339 notify_tip: true,
340 notify_status: false,
341 deactivated_at: None,
342 is_sandbox: false,
343 sandbox_expires_at: None,
344 terminated_at: None,
345 content_removal_at: None,
346 creator_paused_at: None,
347 jwt_invalidated_at: None,
348 sync_jwt_invalidated_at: None,
349 is_founder: false,
350 founder_locked_at: None,
351 feed_key_version: 0,
352 }
353 }
354
355 #[test]
356 fn db_user_stripe_status_not_connected() {
357 let u = make_user(None, false, false);
358 assert_eq!(
359 u.stripe_connection_status(),
360 StripeConnectionStatus::NotConnected
361 );
362 }
363
364 #[test]
365 fn db_user_stripe_status_onboarding() {
366 let u = make_user(Some("acct_123"), false, false);
367 assert_eq!(
368 u.stripe_connection_status(),
369 StripeConnectionStatus::Onboarding
370 );
371 }
372
373 #[test]
374 fn db_user_stripe_status_payouts_pending() {
375 let u = make_user(Some("acct_123"), true, false);
376 assert_eq!(
377 u.stripe_connection_status(),
378 StripeConnectionStatus::PayoutsPending
379 );
380 }
381
382 #[test]
383 fn db_user_stripe_status_active() {
384 let u = make_user(Some("acct_123"), true, true);
385 assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::Active);
386 }
387
388 #[test]
389 fn is_suspended_true_when_set() {
390 let mut u = make_user(None, false, false);
391 u.suspended_at = Some(Utc::now());
392 assert!(u.is_suspended());
393 }
394
395 #[test]
396 fn is_suspended_false_when_none() {
397 let u = make_user(None, false, false);
398 assert!(!u.is_suspended());
399 }
400
401 #[test]
402 fn is_deactivated_true_when_set() {
403 let mut u = make_user(None, false, false);
404 u.deactivated_at = Some(Utc::now());
405 assert!(u.is_deactivated());
406 }
407
408 #[test]
409 fn is_deactivated_false_when_none() {
410 let u = make_user(None, false, false);
411 assert!(!u.is_deactivated());
412 }
413
414 #[test]
415 fn is_creator_paused_true_when_set() {
416 let mut u = make_user(None, false, false);
417 u.creator_paused_at = Some(Utc::now());
418 assert!(u.is_creator_paused());
419 }
420
421 #[test]
422 fn is_creator_paused_false_when_none() {
423 let u = make_user(None, false, false);
424 assert!(!u.is_creator_paused());
425 }
426 }
427