Skip to main content

max / makenotwork

16.7 KB · 416 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 /// Creator-authored profile-page HTML (original source, pre-sanitization).
200 /// Empty string = no customization, render the default profile. Served from
201 /// `u.makenot.work`; see `crate::custom_pages`.
202 pub custom_html: String,
203 /// Creator-authored profile-page CSS (original source, pre-sanitization).
204 pub custom_css: String,
205 /// When the custom page was last saved (cache-key + moderation review).
206 pub custom_pages_updated_at: Option<DateTime<Utc>>,
207 /// Moderation kill switch: while true the editor is read-only.
208 pub custom_pages_locked: bool,
209 }
210
211 impl DbUser {
212 /// Whether this user account is currently suspended.
213 pub fn is_suspended(&self) -> bool {
214 self.suspended_at.is_some()
215 }
216
217 /// Whether this user has self-deactivated their account.
218 pub fn is_deactivated(&self) -> bool {
219 self.deactivated_at.is_some()
220 }
221
222 /// Whether this creator has voluntarily paused their account.
223 pub fn is_creator_paused(&self) -> bool {
224 self.creator_paused_at.is_some()
225 }
226
227 /// Whether founder pricing is permanently locked in for this user. True
228 /// once the founder-window close sweep has stamped `founder_locked_at`.
229 pub fn is_founder_locked(&self) -> bool {
230 self.founder_locked_at.is_some()
231 }
232 }
233
234 impl DbUser {
235 /// Derive the Stripe connection status from the four Stripe fields.
236 pub fn stripe_connection_status(&self) -> StripeConnectionStatus {
237 if self.stripe_account_id.is_none() {
238 StripeConnectionStatus::NotConnected
239 } else if !self.stripe_onboarding_complete {
240 StripeConnectionStatus::Onboarding
241 } else if !self.stripe_payouts_enabled {
242 StripeConnectionStatus::PayoutsPending
243 } else {
244 StripeConnectionStatus::Active
245 }
246 }
247 }
248
249 #[cfg(test)]
250 mod tests {
251 use super::*;
252
253 #[test]
254 fn stripe_status_not_connected() {
255 let status = StripeConnectionStatus::NotConnected;
256 assert_eq!(status.text(), "Not connected");
257 assert_eq!(status.css_class(), "inactive");
258 }
259
260 #[test]
261 fn stripe_status_onboarding() {
262 let status = StripeConnectionStatus::Onboarding;
263 assert_eq!(status.text(), "Onboarding incomplete");
264 assert_eq!(status.css_class(), "pending");
265 }
266
267 #[test]
268 fn stripe_status_payouts_pending() {
269 let status = StripeConnectionStatus::PayoutsPending;
270 assert_eq!(status.text(), "Payouts pending");
271 assert_eq!(status.css_class(), "pending");
272 }
273
274 #[test]
275 fn stripe_status_active() {
276 let status = StripeConnectionStatus::Active;
277 assert_eq!(status.text(), "Active");
278 assert_eq!(status.css_class(), "active");
279 }
280
281 fn make_user(account_id: Option<&str>, onboarding: bool, payouts: bool) -> DbUser {
282 DbUser {
283 id: UserId::nil(),
284 username: Username::from_trusted("test".to_string()),
285 email: Email::from_trusted("test@example.com".to_string()),
286 password_hash: String::new(),
287 display_name: None,
288 bio: None,
289 avatar_url: None,
290 theme_id: None,
291 custom_html: String::new(),
292 custom_css: String::new(),
293 custom_pages_updated_at: None,
294 custom_pages_locked: false,
295 created_at: Utc::now(),
296 updated_at: Utc::now(),
297 settlement_currency: crate::currency::SettlementCurrency::Usd,
298 conversion_preference: crate::currency::ConversionChoice::AtCheckout,
299 stripe_account_id: account_id.map(|s| StripeAccountId::from_trusted(s.to_string())),
300 stripe_onboarding_complete: onboarding,
301 stripe_payouts_enabled: payouts,
302 stripe_charges_enabled: false,
303 stripe_tax_enabled: false,
304 email_verified: false,
305 email_verification_token: None,
306 email_verification_sent_at: None,
307 failed_login_attempts: 0,
308 locked_until: None,
309 last_failed_login_at: None,
310 can_create_projects: false,
311 upload_trusted: false,
312 totp_secret: None,
313 totp_enabled: false,
314 suspended_at: None,
315 suspension_reason: None,
316 appeal_text: None,
317 appeal_submitted_at: None,
318 appeal_decision: None,
319 appeal_response: None,
320 appeal_decided_at: None,
321 last_broadcast_at: None,
322 onboarding_email_step: 0,
323 onboarding_email_sent_at: None,
324 cache_generation: 0,
325 creator_tier: None,
326 storage_used_bytes: 0,
327 max_file_override_bytes: None,
328 grandfathered_until: None,
329 tips_enabled: false,
330 deactivated_at: None,
331 is_sandbox: false,
332 sandbox_expires_at: None,
333 terminated_at: None,
334 content_removal_at: None,
335 creator_paused_at: None,
336 jwt_invalidated_at: None,
337 sync_jwt_invalidated_at: None,
338 is_founder: false,
339 founder_locked_at: None,
340 feed_key_version: 0,
341 }
342 }
343
344 #[test]
345 fn db_user_stripe_status_not_connected() {
346 let u = make_user(None, false, false);
347 assert_eq!(
348 u.stripe_connection_status(),
349 StripeConnectionStatus::NotConnected
350 );
351 }
352
353 #[test]
354 fn db_user_stripe_status_onboarding() {
355 let u = make_user(Some("acct_123"), false, false);
356 assert_eq!(
357 u.stripe_connection_status(),
358 StripeConnectionStatus::Onboarding
359 );
360 }
361
362 #[test]
363 fn db_user_stripe_status_payouts_pending() {
364 let u = make_user(Some("acct_123"), true, false);
365 assert_eq!(
366 u.stripe_connection_status(),
367 StripeConnectionStatus::PayoutsPending
368 );
369 }
370
371 #[test]
372 fn db_user_stripe_status_active() {
373 let u = make_user(Some("acct_123"), true, true);
374 assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::Active);
375 }
376
377 #[test]
378 fn is_suspended_true_when_set() {
379 let mut u = make_user(None, false, false);
380 u.suspended_at = Some(Utc::now());
381 assert!(u.is_suspended());
382 }
383
384 #[test]
385 fn is_suspended_false_when_none() {
386 let u = make_user(None, false, false);
387 assert!(!u.is_suspended());
388 }
389
390 #[test]
391 fn is_deactivated_true_when_set() {
392 let mut u = make_user(None, false, false);
393 u.deactivated_at = Some(Utc::now());
394 assert!(u.is_deactivated());
395 }
396
397 #[test]
398 fn is_deactivated_false_when_none() {
399 let u = make_user(None, false, false);
400 assert!(!u.is_deactivated());
401 }
402
403 #[test]
404 fn is_creator_paused_true_when_set() {
405 let mut u = make_user(None, false, false);
406 u.creator_paused_at = Some(Utc::now());
407 assert!(u.is_creator_paused());
408 }
409
410 #[test]
411 fn is_creator_paused_false_when_none() {
412 let u = make_user(None, false, false);
413 assert!(!u.is_creator_paused());
414 }
415 }
416