//! User account model and Stripe connection status. use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::FromRow; use super::super::id_types::*; use super::super::validated_types::*; /// Derived Stripe Connect state machine. /// /// Computed from `stripe_account_id`, `stripe_onboarding_complete`, and /// `stripe_payouts_enabled`. The `stripe_charges_enabled` field is a /// separate concern (whether the account can accept payments) and is /// checked independently in checkout routes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StripeConnectionStatus { /// No `stripe_account_id` set. NotConnected, /// Account connected but onboarding not yet completed. Onboarding, /// Onboarding complete but payouts not yet enabled by Stripe. PayoutsPending, /// Fully connected: onboarding complete and payouts enabled. Active, } impl StripeConnectionStatus { /// Human-readable label for dashboard display. pub fn text(&self) -> &'static str { match self { Self::NotConnected => "Not connected", Self::Onboarding => "Onboarding incomplete", Self::PayoutsPending => "Payouts pending", Self::Active => "Active", } } /// CSS class for status badge rendering. pub fn css_class(&self) -> &'static str { match self { Self::NotConnected => "inactive", Self::Onboarding | Self::PayoutsPending => "pending", Self::Active => "active", } } } /// A registered user account. /// /// **Stripe Connect state machine:** The four Stripe fields form a linear /// progression: `NotConnected` (no account_id) → `Onboarding` (account_id /// but `!onboarding_complete`) → `PayoutsPending` (`onboarding_complete` /// but `!payouts_enabled`) → `Active` (`payouts_enabled`). /// Use [`DbUser::stripe_connection_status()`] to get the derived state. #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbUser { /// Database primary key. pub id: UserId, /// Unique login handle. pub username: Username, /// Unique email address. Normalized (trimmed + lowercased) at write time /// via [`Email::new`]; DB reads use `from_trusted`. pub email: Email, /// Argon2-hashed password. pub password_hash: String, /// Optional human-readable name shown on profile. pub display_name: Option, /// Optional short biography. pub bio: Option, /// URL to the user's avatar image. pub avatar_url: Option, /// When the account was created. pub created_at: DateTime, /// When the account was last modified. pub updated_at: DateTime, // Stripe Connect fields (see struct-level doc for state machine) /// Stripe Connect account ID (e.g. `acct_...`). None = not connected. pub stripe_account_id: Option, /// Whether Stripe onboarding has been completed. Only meaningful when `stripe_account_id` is Some. pub stripe_onboarding_complete: bool, /// Whether Stripe payouts are enabled. Only meaningful when `stripe_onboarding_complete` is true. pub stripe_payouts_enabled: bool, /// Whether Stripe charges (payments) are enabled. Checked independently in checkout routes. pub stripe_charges_enabled: bool, /// Whether the creator has opted in to Stripe Tax (automatic tax calculation at checkout). pub stripe_tax_enabled: bool, // Email verification /// Whether the user's email address has been verified. pub email_verified: bool, /// One-time token sent for email verification. pub email_verification_token: Option, /// When the verification email was last sent. pub email_verification_sent_at: Option>, // Account lockout /// Consecutive failed login attempts since last success. pub failed_login_attempts: i32, /// Account is locked until this timestamp (if set). pub locked_until: Option>, /// Timestamp of the most recent failed login attempt. pub last_failed_login_at: Option>, // Creator access /// Whether this user is allowed to create projects. pub can_create_projects: bool, /// Whether this user's uploads skip the review queue (trusted = auto-publish). pub upload_trusted: bool, // Notification preferences /// Whether to email the user on new device logins. pub login_notification_enabled: bool, // Two-factor authentication /// Base32-encoded TOTP secret (set during setup, cleared on disable). pub totp_secret: Option, /// Whether TOTP 2FA is currently active for this account. pub totp_enabled: bool, // Suspension /// When the account was suspended (None = not suspended). pub suspended_at: Option>, /// Reason provided by admin when suspending the account. pub suspension_reason: Option, /// User's appeal text (if they've appealed the suspension). pub appeal_text: Option, /// When the appeal was submitted. pub appeal_submitted_at: Option>, /// Admin decision on appeal: "approved" or "denied". pub appeal_decision: Option, /// Admin response text explaining the decision. pub appeal_response: Option, /// When the appeal was decided. pub appeal_decided_at: Option>, // Email notification preferences /// Whether to email the creator when they make a sale. pub notify_sale: bool, /// Whether to email the creator when they gain a follower. pub notify_follower: bool, /// Whether to email the user when creators they follow publish new content. pub notify_release: bool, /// Whether to email the repo owner about new issues and comments. pub notify_issues: bool, /// When the creator last sent a broadcast email (rate limiting). pub last_broadcast_at: Option>, // Onboarding email drip /// Current step in the getting-started email sequence (0 = none sent, 3 = complete). pub onboarding_email_step: i16, /// When the last onboarding email was sent. pub onboarding_email_sent_at: Option>, /// Generation counter for ETag-based HTTP caching. Bumped on any user-visible write. pub cache_generation: i64, /// Denormalized creator tier (synced from creator_subscriptions on checkout/update/cancel). pub creator_tier: Option, /// Total bytes of uploaded files (audio, covers, downloads, insertions). pub storage_used_bytes: i64, /// Admin-set per-file size override in bytes (None = use tier default). pub max_file_override_bytes: Option, /// Grandfathering deadline: SmallFiles-equivalent access until this date. pub grandfathered_until: Option>, /// Whether this creator accepts tips on their profile/project pages. pub tips_enabled: bool, /// Whether to email the creator when they receive a tip. pub notify_tip: bool, /// Whether to email the user when platform status changes (opt-in). pub notify_status: bool, /// When the user self-deactivated their account (None = active). pub deactivated_at: Option>, /// Whether this is an ephemeral sandbox account. pub is_sandbox: bool, /// When the sandbox session expires (cleanup deletes the user after this). pub sandbox_expires_at: Option>, /// When the admin permanently terminated this account (None = not terminated). /// User has 30 days from this timestamp to export data before deletion. pub terminated_at: Option>, /// When content should be removed after creator self-deletion. /// Buyers can still download purchased items until this date (90-day grace). /// After this passes, the scheduler deletes S3 objects and the user row. pub content_removal_at: Option>, /// When the creator voluntarily paused their account (None = not paused). /// Fan subscriptions are set to cancel_at_period_end (graceful expiry), /// new purchases are blocked, content remains hosted indefinitely. pub creator_paused_at: Option>, /// When JWTs issued before this timestamp should be rejected (set on password change). pub jwt_invalidated_at: Option>, /// Whether this user started a creator-tier subscription during the /// founder window. Sticky once true; never reset. Used by checkout to /// select founder price IDs before the window closes; after close, the /// `founder_locked_at` field is the source of truth for ongoing eligibility. pub is_founder: bool, /// When founder pricing was locked in for this user. NULL until the /// window closes; set by the close-window admin sweep ONLY for users with /// an active creator-tier subscription at close-time. Non-NULL means /// founder prices apply to all current and future creator-tier /// subscriptions on this account. NULL after the close means "lost /// eligibility"; they pay sticker prices on any future subscription. pub founder_locked_at: Option>, /// Version counter folded into the personal-feed URL HMAC. Bumping it (via /// the "Regenerate feed URL" dashboard action) revokes the user's existing /// feed link without rotating the global signing secret. Starts at 0. pub feed_key_version: i32, } impl DbUser { /// Whether this user account is currently suspended. pub fn is_suspended(&self) -> bool { self.suspended_at.is_some() } /// Whether this user has self-deactivated their account. pub fn is_deactivated(&self) -> bool { self.deactivated_at.is_some() } /// Whether this creator has voluntarily paused their account. pub fn is_creator_paused(&self) -> bool { self.creator_paused_at.is_some() } /// Whether founder pricing is permanently locked in for this user. True /// once the founder-window close sweep has stamped `founder_locked_at`. pub fn is_founder_locked(&self) -> bool { self.founder_locked_at.is_some() } } impl DbUser { /// Derive the Stripe connection status from the four Stripe fields. pub fn stripe_connection_status(&self) -> StripeConnectionStatus { if self.stripe_account_id.is_none() { StripeConnectionStatus::NotConnected } else if !self.stripe_onboarding_complete { StripeConnectionStatus::Onboarding } else if !self.stripe_payouts_enabled { StripeConnectionStatus::PayoutsPending } else { StripeConnectionStatus::Active } } } #[cfg(test)] mod tests { use super::*; #[test] fn stripe_status_not_connected() { let status = StripeConnectionStatus::NotConnected; assert_eq!(status.text(), "Not connected"); assert_eq!(status.css_class(), "inactive"); } #[test] fn stripe_status_onboarding() { let status = StripeConnectionStatus::Onboarding; assert_eq!(status.text(), "Onboarding incomplete"); assert_eq!(status.css_class(), "pending"); } #[test] fn stripe_status_payouts_pending() { let status = StripeConnectionStatus::PayoutsPending; assert_eq!(status.text(), "Payouts pending"); assert_eq!(status.css_class(), "pending"); } #[test] fn stripe_status_active() { let status = StripeConnectionStatus::Active; assert_eq!(status.text(), "Active"); assert_eq!(status.css_class(), "active"); } fn make_user(account_id: Option<&str>, onboarding: bool, payouts: bool) -> DbUser { DbUser { id: UserId::nil(), username: Username::from_trusted("test".to_string()), email: Email::from_trusted("test@example.com".to_string()), password_hash: String::new(), display_name: None, bio: None, avatar_url: None, created_at: Utc::now(), updated_at: Utc::now(), stripe_account_id: account_id.map(|s| s.to_string()), stripe_onboarding_complete: onboarding, stripe_payouts_enabled: payouts, stripe_charges_enabled: false, stripe_tax_enabled: false, email_verified: false, email_verification_token: None, email_verification_sent_at: None, failed_login_attempts: 0, locked_until: None, last_failed_login_at: None, can_create_projects: false, upload_trusted: false, login_notification_enabled: true, totp_secret: None, totp_enabled: false, suspended_at: None, suspension_reason: None, appeal_text: None, appeal_submitted_at: None, appeal_decision: None, appeal_response: None, appeal_decided_at: None, notify_sale: true, notify_follower: true, notify_release: true, notify_issues: true, last_broadcast_at: None, onboarding_email_step: 0, onboarding_email_sent_at: None, cache_generation: 0, creator_tier: None, storage_used_bytes: 0, max_file_override_bytes: None, grandfathered_until: None, tips_enabled: false, notify_tip: true, notify_status: false, deactivated_at: None, is_sandbox: false, sandbox_expires_at: None, terminated_at: None, content_removal_at: None, creator_paused_at: None, jwt_invalidated_at: None, is_founder: false, founder_locked_at: None, feed_key_version: 0, } } #[test] fn db_user_stripe_status_not_connected() { let u = make_user(None, false, false); assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::NotConnected); } #[test] fn db_user_stripe_status_onboarding() { let u = make_user(Some("acct_123"), false, false); assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::Onboarding); } #[test] fn db_user_stripe_status_payouts_pending() { let u = make_user(Some("acct_123"), true, false); assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::PayoutsPending); } #[test] fn db_user_stripe_status_active() { let u = make_user(Some("acct_123"), true, true); assert_eq!(u.stripe_connection_status(), StripeConnectionStatus::Active); } #[test] fn is_suspended_true_when_set() { let mut u = make_user(None, false, false); u.suspended_at = Some(Utc::now()); assert!(u.is_suspended()); } #[test] fn is_suspended_false_when_none() { let u = make_user(None, false, false); assert!(!u.is_suspended()); } #[test] fn is_deactivated_true_when_set() { let mut u = make_user(None, false, false); u.deactivated_at = Some(Utc::now()); assert!(u.is_deactivated()); } #[test] fn is_deactivated_false_when_none() { let u = make_user(None, false, false); assert!(!u.is_deactivated()); } #[test] fn is_creator_paused_true_when_set() { let mut u = make_user(None, false, false); u.creator_paused_at = Some(Utc::now()); assert!(u.is_creator_paused()); } #[test] fn is_creator_paused_false_when_none() { let u = make_user(None, false, false); assert!(!u.is_creator_paused()); } }