Skip to main content

max / makenotwork

17.2 KB · 425 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 /// The one currency this creator is paid in, mirrored from `default_currency`
89 /// on their Stripe account. Every price they set is denominated in it, and a
90 /// checkout session for them is created in it. USD until Stripe tells us
91 /// otherwise, which is also what an account with no Stripe connection reads as.
92 pub settlement_currency: crate::currency::SettlementCurrency,
93 /// As a *buyer*: how this user wants a cross-currency purchase converted.
94 /// Unrelated to `settlement_currency`, which is about being paid.
95 pub conversion_preference: crate::currency::ConversionChoice,
96 // Email verification
97 /// Whether the user's email address has been verified.
98 pub email_verified: bool,
99 /// One-time token sent for email verification.
100 pub email_verification_token: Option<String>,
101 /// When the verification email was last sent.
102 pub email_verification_sent_at: Option<DateTime<Utc>>,
103 // Account lockout
104 /// Consecutive failed login attempts since last success.
105 pub failed_login_attempts: i32,
106 /// Account is locked until this timestamp (if set).
107 pub locked_until: Option<DateTime<Utc>>,
108 /// Timestamp of the most recent failed login attempt.
109 pub last_failed_login_at: Option<DateTime<Utc>>,
110 // Creator access
111 /// Whether this user is allowed to create projects.
112 pub can_create_projects: bool,
113 /// Whether this user's uploads skip the review queue (trusted = auto-publish).
114 pub upload_trusted: bool,
115 // Notification preferences
116 // Two-factor authentication
117 /// Base32-encoded TOTP secret (set during setup, cleared on disable).
118 pub totp_secret: Option<String>,
119 /// Whether TOTP 2FA is currently active for this account.
120 pub totp_enabled: bool,
121 // Suspension
122 /// When the account was suspended (None = not suspended).
123 pub suspended_at: Option<DateTime<Utc>>,
124 /// Reason provided by admin when suspending the account.
125 pub suspension_reason: Option<String>,
126 /// User's appeal text (if they've appealed the suspension).
127 pub appeal_text: Option<String>,
128 /// When the appeal was submitted.
129 pub appeal_submitted_at: Option<DateTime<Utc>>,
130 /// Admin decision on appeal: "approved" or "denied".
131 pub appeal_decision: Option<String>,
132 /// Admin response text explaining the decision.
133 pub appeal_response: Option<String>,
134 /// When the appeal was decided.
135 pub appeal_decided_at: Option<DateTime<Utc>>,
136 // Email notification preferences
137 /// When the creator last sent a broadcast email (rate limiting).
138 pub last_broadcast_at: Option<DateTime<Utc>>,
139 // Onboarding email drip
140 /// Current step in the getting-started email sequence (0 = none sent, 3 = complete).
141 pub onboarding_email_step: i16,
142 /// When the last onboarding email was sent.
143 pub onboarding_email_sent_at: Option<DateTime<Utc>>,
144 /// Generation counter for ETag-based HTTP caching. Bumped on any user-visible write.
145 pub cache_generation: i64,
146 /// Denormalized creator tier (synced from creator_subscriptions on checkout/update/cancel).
147 pub creator_tier: Option<String>,
148 /// Total bytes of uploaded files (audio, covers, downloads, insertions).
149 pub storage_used_bytes: i64,
150 /// Admin-set per-file size override in bytes (None = use tier default).
151 pub max_file_override_bytes: Option<i64>,
152 /// Grandfathering deadline: SmallFiles-equivalent access until this date.
153 pub grandfathered_until: Option<DateTime<Utc>>,
154 /// Whether this creator accepts tips on their profile/project pages.
155 pub tips_enabled: bool,
156 /// When the user self-deactivated their account (None = active).
157 pub deactivated_at: Option<DateTime<Utc>>,
158 /// Whether this is an ephemeral sandbox account.
159 pub is_sandbox: bool,
160 /// When the sandbox session expires (cleanup deletes the user after this).
161 pub sandbox_expires_at: Option<DateTime<Utc>>,
162 /// When the admin permanently terminated this account (None = not terminated).
163 /// User has 30 days from this timestamp to export data before deletion.
164 pub terminated_at: Option<DateTime<Utc>>,
165 /// When content should be removed after creator self-deletion.
166 /// Buyers can still download purchased items until this date (90-day grace).
167 /// After this passes, the scheduler deletes S3 objects and the user row.
168 pub content_removal_at: Option<DateTime<Utc>>,
169 /// When the creator voluntarily paused their account (None = not paused).
170 /// Fan subscriptions are set to cancel_at_period_end (graceful expiry),
171 /// new purchases are blocked, content remains hosted indefinitely.
172 pub creator_paused_at: Option<DateTime<Utc>>,
173 /// When JWTs issued before this timestamp should be rejected (set on password change).
174 pub jwt_invalidated_at: Option<DateTime<Utc>>,
175 /// When SyncKit JWTs issued before this timestamp should be rejected. Set on
176 /// a sync-device removal so the removed device's token dies immediately.
177 /// Separate from `jwt_invalidated_at` so a sync revocation does not log the
178 /// user out of the website.
179 pub sync_jwt_invalidated_at: Option<DateTime<Utc>>,
180 /// Whether this user started a creator-tier subscription during the
181 /// founder window. Sticky once true; never reset. Used by checkout to
182 /// select founder price IDs before the window closes; after close, the
183 /// `founder_locked_at` field is the source of truth for ongoing eligibility.
184 pub is_founder: bool,
185 /// When founder pricing was locked in for this user. NULL until the
186 /// window closes; set by the close-window admin sweep ONLY for users with
187 /// an active creator-tier subscription at close-time. Non-NULL means
188 /// founder prices apply to all current and future creator-tier
189 /// subscriptions on this account. NULL after the close means "lost
190 /// eligibility"; they pay sticker prices on any future subscription.
191 pub founder_locked_at: Option<DateTime<Utc>>,
192 /// Version counter folded into the personal-feed URL HMAC. Bumping it (via
193 /// the "Regenerate feed URL" dashboard action) revokes the user's existing
194 /// feed link without rotating the global signing secret. Starts at 0.
195 pub feed_key_version: i32,
196 /// Chosen built-in theme id for this creator's public profile page. `None` =
197 /// the platform default. References a bundled theme; see `crate::theming`.
198 pub theme_id: Option<String>,
199 /// The creator's own SSH console theme, as a `makeover::ThemeSelection`
200 /// string: a bundled theme id, or `"system"` to follow the terminal. `None`
201 /// = never chosen, which resolves the same way `"system"` does.
202 ///
203 /// Deliberately not `theme_id`: that one is what visitors see, this one is
204 /// the creator's private chrome, and a brand palette is a poor default for
205 /// a terminal whose ambient mode it cannot know.
206 pub console_theme: Option<String>,
207 /// Creator-authored profile-page HTML (original source, pre-sanitization).
208 /// Empty string = no customization, render the default profile. Served from
209 /// `u.makenot.work`; see `crate::custom_pages`.
210 pub custom_html: String,
211 /// Creator-authored profile-page CSS (original source, pre-sanitization).
212 pub custom_css: String,
213 /// When the custom page was last saved (cache-key + moderation review).
214 pub custom_pages_updated_at: Option<DateTime<Utc>>,
215 /// Moderation kill switch: while true the editor is read-only.
216 pub custom_pages_locked: bool,
217 }
218
219 impl DbUser {
220 /// Whether this user account is currently suspended.
221 pub fn is_suspended(&self) -> bool {
222 self.suspended_at.is_some()
223 }
224
225 /// Whether this user has self-deactivated their account.
226 pub fn is_deactivated(&self) -> bool {
227 self.deactivated_at.is_some()
228 }
229
230 /// Whether this creator has voluntarily paused their account.
231 pub fn is_creator_paused(&self) -> bool {
232 self.creator_paused_at.is_some()
233 }
234
235 /// Whether founder pricing is permanently locked in for this user. True
236 /// once the founder-window close sweep has stamped `founder_locked_at`.
237 pub fn is_founder_locked(&self) -> bool {
238 self.founder_locked_at.is_some()
239 }
240 }
241
242 impl DbUser {
243 /// Derive the Stripe connection status from the four Stripe fields.
244 pub fn stripe_connection_status(&self) -> StripeConnectionStatus {
245 if self.stripe_account_id.is_none() {
246 StripeConnectionStatus::NotConnected
247 } else if !self.stripe_onboarding_complete {
248 StripeConnectionStatus::Onboarding
249 } else if !self.stripe_payouts_enabled {
250 StripeConnectionStatus::PayoutsPending
251 } else {
252 StripeConnectionStatus::Active
253 }
254 }
255 }
256
257 #[cfg(test)]
258 mod tests {
259 use super::*;
260
261 #[test]
262 fn stripe_status_not_connected() {
263 let status = StripeConnectionStatus::NotConnected;
264 assert_eq!(status.text(), "Not connected");
265 assert_eq!(status.css_class(), "inactive");
266 }
267
268 #[test]
269 fn stripe_status_onboarding() {
270 let status = StripeConnectionStatus::Onboarding;
271 assert_eq!(status.text(), "Onboarding incomplete");
272 assert_eq!(status.css_class(), "pending");
273 }
274
275 #[test]
276 fn stripe_status_payouts_pending() {
277 let status = StripeConnectionStatus::PayoutsPending;
278 assert_eq!(status.text(), "Payouts pending");
279 assert_eq!(status.css_class(), "pending");
280 }
281
282 #[test]
283 fn stripe_status_active() {
284 let status = StripeConnectionStatus::Active;
285 assert_eq!(status.text(), "Active");
286 assert_eq!(status.css_class(), "active");
287 }
288
289 fn make_user(account_id: Option<&str>, onboarding: bool, payouts: bool) -> DbUser {
290 DbUser {
291 id: UserId::nil(),
292 username: Username::from_trusted("test".to_string()),
293 email: Email::from_trusted("test@example.com".to_string()),
294 password_hash: String::new(),
295 display_name: None,
296 bio: None,
297 avatar_url: None,
298 theme_id: None,
299 console_theme: None,
300 custom_html: String::new(),
301 custom_css: String::new(),
302 custom_pages_updated_at: None,
303 custom_pages_locked: false,
304 created_at: Utc::now(),
305 updated_at: Utc::now(),
306 settlement_currency: crate::currency::SettlementCurrency::Usd,
307 conversion_preference: crate::currency::ConversionChoice::AtCheckout,
308 stripe_account_id: account_id.map(|s| StripeAccountId::from_trusted(s.to_string())),
309 stripe_onboarding_complete: onboarding,
310 stripe_payouts_enabled: payouts,
311 stripe_charges_enabled: false,
312 stripe_tax_enabled: false,
313 email_verified: false,
314 email_verification_token: None,
315 email_verification_sent_at: None,
316 failed_login_attempts: 0,
317 locked_until: None,
318 last_failed_login_at: None,
319 can_create_projects: false,
320 upload_trusted: false,
321 totp_secret: None,
322 totp_enabled: false,
323 suspended_at: None,
324 suspension_reason: None,
325 appeal_text: None,
326 appeal_submitted_at: None,
327 appeal_decision: None,
328 appeal_response: None,
329 appeal_decided_at: None,
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 deactivated_at: None,
340 is_sandbox: false,
341 sandbox_expires_at: None,
342 terminated_at: None,
343 content_removal_at: None,
344 creator_paused_at: None,
345 jwt_invalidated_at: None,
346 sync_jwt_invalidated_at: None,
347 is_founder: false,
348 founder_locked_at: None,
349 feed_key_version: 0,
350 }
351 }
352
353 #[test]
354 fn db_user_stripe_status_not_connected() {
355 let u = make_user(None, false, false);
356 assert_eq!(
357 u.stripe_connection_status(),
358 StripeConnectionStatus::NotConnected
359 );
360 }
361
362 #[test]
363 fn db_user_stripe_status_onboarding() {
364 let u = make_user(Some("acct_123"), false, false);
365 assert_eq!(
366 u.stripe_connection_status(),
367 StripeConnectionStatus::Onboarding
368 );
369 }
370
371 #[test]
372 fn db_user_stripe_status_payouts_pending() {
373 let u = make_user(Some("acct_123"), true, false);
374 assert_eq!(
375 u.stripe_connection_status(),
376 StripeConnectionStatus::PayoutsPending
377 );
378 }
379
380 #[test]
381 fn db_user_stripe_status_active() {
382 let u = make_user(Some("acct_123"), true, true);
383 assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::Active);
384 }
385
386 #[test]
387 fn is_suspended_true_when_set() {
388 let mut u = make_user(None, false, false);
389 u.suspended_at = Some(Utc::now());
390 assert!(u.is_suspended());
391 }
392
393 #[test]
394 fn is_suspended_false_when_none() {
395 let u = make_user(None, false, false);
396 assert!(!u.is_suspended());
397 }
398
399 #[test]
400 fn is_deactivated_true_when_set() {
401 let mut u = make_user(None, false, false);
402 u.deactivated_at = Some(Utc::now());
403 assert!(u.is_deactivated());
404 }
405
406 #[test]
407 fn is_deactivated_false_when_none() {
408 let u = make_user(None, false, false);
409 assert!(!u.is_deactivated());
410 }
411
412 #[test]
413 fn is_creator_paused_true_when_set() {
414 let mut u = make_user(None, false, false);
415 u.creator_paused_at = Some(Utc::now());
416 assert!(u.is_creator_paused());
417 }
418
419 #[test]
420 fn is_creator_paused_false_when_none() {
421 let u = make_user(None, false, false);
422 assert!(!u.is_creator_paused());
423 }
424 }
425