//! Subscription tier, subscription, and related export models. use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::FromRow; use uuid::Uuid; use super::super::enums::CreatorTier; use super::super::id_types::*; use super::super::validated_types::*; /// A subscription tier, scoped to either a project or an item. #[derive(Debug, Clone, FromRow)] pub struct DbSubscriptionTier { pub id: SubscriptionTierId, pub project_id: Option, pub name: String, pub description: Option, pub price_cents: i32, pub stripe_product_id: Option, pub stripe_price_id: Option, pub sort_order: i32, pub is_active: bool, pub created_at: DateTime, pub updated_at: DateTime, pub item_id: Option, } /// Active subscription billing period. #[derive(Debug, Clone)] pub struct SubscriptionPeriod { /// Start of the current billing period. pub start: DateTime, /// End of the current billing period. pub end: DateTime, } /// A user's subscription to a project or item tier. /// /// **State invariant:** When `status` is `Active` or `PastDue`, /// `current_period_start` and `current_period_end` are both `Some`. /// When `status == Canceled`, `canceled_at` is `Some`. /// When `status == Unpaid`, period fields may or may not be set. /// Exactly one of `project_id` or `item_id` is `Some`. #[derive(Debug, Clone, FromRow)] pub struct DbSubscription { pub id: SubscriptionId, pub subscriber_id: UserId, pub tier_id: SubscriptionTierId, pub project_id: Option, pub stripe_subscription_id: String, pub stripe_customer_id: String, pub status: super::super::SubscriptionStatus, /// Start of current billing period. Present when `status` is `Active` or `PastDue`. pub current_period_start: Option>, /// End of current billing period. Present when `status` is `Active` or `PastDue`. pub current_period_end: Option>, /// When the subscription was canceled. Present when `status == Canceled`. pub canceled_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, pub item_id: Option, /// When this subscription was paused due to creator suspension (None = not paused). pub paused_at: Option>, } impl DbSubscription { /// Extract the active billing period as a coherent unit. /// /// Returns `Some` when both period bounds are present (typically /// `Active` or `PastDue` status). pub fn active_period(&self) -> Option { Some(SubscriptionPeriod { start: self.current_period_start?, end: self.current_period_end?, }) } } /// A webhook event log entry for subscription debugging and idempotency. #[derive(Debug, Clone, FromRow)] #[allow(dead_code)] // Fields populated by sqlx query pub struct DbSubscriptionEvent { pub id: SubscriptionEventId, pub subscription_id: Option, pub stripe_event_id: String, pub event_type: String, pub payload: serde_json::Value, pub created_at: DateTime, } /// A user subscription joined with project and tier data for the library page. #[derive(Debug, Clone, FromRow)] pub struct DbUserSubscriptionRow { pub id: SubscriptionId, pub project_id: ProjectId, pub project_title: String, pub project_slug: Slug, pub tier_name: String, pub price_cents: i32, pub status: super::super::SubscriptionStatus, pub current_period_end: Option>, pub stripe_subscription_id: String, } // ── Export query models ── /// A follower row for CSV export. /// /// The `email` field is only populated when the follower has a completed /// purchase with `share_contact = true` and no active contact revocation. #[derive(Debug, Clone, FromRow)] pub struct FollowerExportRow { pub username: String, pub display_name: Option, pub target_type: super::super::FollowTargetType, pub created_at: DateTime, /// Shared email (only when buyer opted in and has not revoked). pub email: Option, } /// A subscriber row for CSV export. #[derive(Debug, Clone, FromRow)] pub struct SubscriberExportRow { pub username: String, pub display_name: Option, pub tier_name: String, pub status: super::super::SubscriptionStatus, pub created_at: DateTime, } /// A subscription row for the dedicated subscription CSV export. #[derive(Debug, Clone, FromRow)] pub struct SubscriptionExportRow { pub project_title: String, pub tier_name: String, pub price_cents: i32, pub username: String, pub status: super::super::SubscriptionStatus, pub current_period_start: Option>, pub current_period_end: Option>, pub canceled_at: Option>, pub created_at: DateTime, } /// A Fan+ consumer subscription. #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbFanPlusSubscription { /// Database primary key. pub id: FanPlusSubscriptionId, /// Subscribing user's ID. pub user_id: UserId, /// Stripe subscription ID (e.g. `sub_...`). pub stripe_subscription_id: String, /// Stripe customer ID (e.g. `cus_...`). pub stripe_customer_id: String, /// Subscription status (active, past_due, canceled). pub status: super::super::SubscriptionStatus, /// Start of current billing period. pub current_period_start: Option>, /// End of current billing period. pub current_period_end: Option>, /// When the subscription was created. pub created_at: DateTime, /// When the subscription was canceled. pub canceled_at: Option>, /// Whether the subscription is scheduled to cancel at `current_period_end`. /// True after the user clicks Cancel on the dashboard or in Stripe's /// customer portal; cleared if they click Resume before the period ends. pub cancel_at_period_end: bool, } /// A creator tier subscription (platform billing for creator features). #[derive(Debug, Clone, FromRow, Serialize)] pub struct DbCreatorSubscription { /// Database primary key. pub id: Uuid, /// Subscribing creator's user ID. pub user_id: UserId, /// Stripe subscription ID (e.g. `sub_...`). pub stripe_subscription_id: String, /// Stripe customer ID (e.g. `cus_...`). pub stripe_customer_id: String, /// Creator tier (basic, small_files, big_files, streaming). pub tier: CreatorTier, /// Subscription status (active, past_due, canceled). pub status: super::super::SubscriptionStatus, /// Start of current billing period. pub current_period_start: Option>, /// End of current billing period. pub current_period_end: Option>, /// When the subscription was canceled. pub canceled_at: Option>, /// When the subscription was created. pub created_at: DateTime, /// When post-grace enforcement was applied (items hidden). pub grace_enforced_at: Option>, } #[cfg(test)] mod tests { use super::*; fn make_subscription( status: super::super::super::SubscriptionStatus, period_start: Option>, period_end: Option>, canceled: Option>, ) -> DbSubscription { DbSubscription { id: SubscriptionId::nil(), subscriber_id: UserId::nil(), tier_id: SubscriptionTierId::nil(), project_id: Some(ProjectId::nil()), stripe_subscription_id: "sub_123".to_string(), stripe_customer_id: "cus_123".to_string(), status, current_period_start: period_start, current_period_end: period_end, canceled_at: canceled, created_at: Utc::now(), updated_at: Utc::now(), item_id: None, paused_at: None, } } #[test] fn active_period_for_active_subscription() { let start = Utc::now(); let end = start + chrono::Duration::days(30); let s = make_subscription( super::super::super::SubscriptionStatus::Active, Some(start), Some(end), None, ); let period = s.active_period().unwrap(); assert_eq!(period.start, start); assert_eq!(period.end, end); } #[test] fn active_period_none_for_canceled() { let s = make_subscription( super::super::super::SubscriptionStatus::Canceled, None, None, Some(Utc::now()), ); assert!(s.active_period().is_none()); } #[test] fn active_period_for_past_due() { let start = Utc::now(); let end = start + chrono::Duration::days(30); let s = make_subscription( super::super::super::SubscriptionStatus::PastDue, Some(start), Some(end), None, ); assert!(s.active_period().is_some()); } }