diff --git a/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs b/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs index eada4472b0..a463bb568d 100644 --- a/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs @@ -70,7 +70,6 @@ pub async fn stripe_webhook_handler( StripeWebhookError::BadRequest })?; - // Events can be re-sent, so we need to make handling them idempotent. let stripe_event_id = webhook_payload.id.to_string(); diff --git a/crates/service/storyteller_web/src/configs/mod.rs b/crates/service/storyteller_web/src/configs/mod.rs index 0918f71344..0a735a5b5c 100644 --- a/crates/service/storyteller_web/src/configs/mod.rs +++ b/crates/service/storyteller_web/src/configs/mod.rs @@ -1 +1,2 @@ +pub mod plans; pub mod static_api_tokens; \ No newline at end of file diff --git a/crates/service/storyteller_web/src/configs/plans/mod.rs b/crates/service/storyteller_web/src/configs/plans/mod.rs new file mode 100644 index 0000000000..f4fc64494f --- /dev/null +++ b/crates/service/storyteller_web/src/configs/plans/mod.rs @@ -0,0 +1,4 @@ +pub mod plan; +pub mod plan_builder; +pub mod plan_category; +pub mod plan_list; \ No newline at end of file diff --git a/crates/service/storyteller_web/src/configs/plans/plan.rs b/crates/service/storyteller_web/src/configs/plans/plan.rs new file mode 100644 index 0000000000..ad05e9fb2c --- /dev/null +++ b/crates/service/storyteller_web/src/configs/plans/plan.rs @@ -0,0 +1,77 @@ +use chrono::Duration; +use crate::configs::plans::plan_builder::PlanBuilder; +use crate::configs::plans::plan_category::PlanCategory; + + +/// A Plan is either a free or premium plan. +/// Each plan corresponds to a certain level of service. +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct Plan { + /// Name of the plan + plan_name: String, + + /// Whether the plan is free, paid, or loyalty-based + plan_category: PlanCategory, + + /// Non-authoritative cost of the plan per month in USD equivalent. + /// This is mostly for documentation. Stripe will remain the source + /// of truth. + cost_per_month_dollars: Option, + + /// For lookup against Stripe product ID + stripe_product_id: Option, + + /// For lookup against Stripe price ID + stripe_price_id: Option, + + /// Whether this plan is only active in development + is_development_plan: bool, + + // ========== Features for TTS ========== + + tts_base_priority_level: u8, + tts_max_duration: Duration, + tts_can_generate_mp3: bool, + tts_can_upload_private_models: bool, + tts_can_share_private_models: bool, + + // ========== Features for Voice Conversion ========== + + vc_max_concurrent_models: u32, + + vc_pre_recorded_time_limit: Duration, + vc_pre_recorded_time_is_unlimited: bool, + + vc_real_time_time_limit: Duration, + vc_real_time_time_is_unlimited: bool, + + // ========== Features for W2L ========== + + w2l_max_duration: Duration, + w2l_can_turn_off_watermark: bool, +} + +impl Plan { + pub fn from_builder(builder: &PlanBuilder) -> Self { + Self { + plan_name: builder.plan_name.clone(), + plan_category: builder.plan_category, + cost_per_month_dollars: builder.cost_per_month_dollars.clone(), + stripe_product_id: builder.stripe_product_id.clone(), + stripe_price_id: builder.stripe_price_id.clone(), + is_development_plan: builder.is_development_plan, + tts_base_priority_level: builder.tts_base_priority_level, + tts_max_duration: builder.tts_max_duration, + tts_can_generate_mp3: builder.tts_can_generate_mp3, + tts_can_upload_private_models: builder.tts_can_upload_private_models, + tts_can_share_private_models: builder.tts_can_share_private_models, + vc_max_concurrent_models: builder.vc_max_concurrent_models, + vc_pre_recorded_time_limit: builder.vc_pre_recorded_time_limit, + vc_pre_recorded_time_is_unlimited: builder.vc_pre_recorded_time_is_unlimited, + vc_real_time_time_limit: builder.vc_real_time_time_limit, + vc_real_time_time_is_unlimited: builder.vc_real_time_time_is_unlimited, + w2l_max_duration: builder.w2l_max_duration, + w2l_can_turn_off_watermark: builder.w2l_can_turn_off_watermark, + } + } +} \ No newline at end of file diff --git a/crates/service/storyteller_web/src/configs/plans/plan_builder.rs b/crates/service/storyteller_web/src/configs/plans/plan_builder.rs new file mode 100644 index 0000000000..80d95945af --- /dev/null +++ b/crates/service/storyteller_web/src/configs/plans/plan_builder.rs @@ -0,0 +1,173 @@ +use chrono::Duration; +use crate::configs::plans::plan::Plan; +use crate::configs::plans::plan_category::PlanCategory; + +const TTS_DEFAULT_PRIORITY_LEVEL : u8 = 0; +const TTS_DEFAULT_DURATION_SECONDS : i64 = 12; + +const VC_DEFAULT_MAX_CONCURRENT_MODELS : u32 = 5; + +const VC_DEFAULT_PRE_RECORDED_TIME_LIMIT_SECONDS : i64 = 20; +const VC_DEFAULT_REAL_TIME_TIME_LIMIT_SECONDS : i64 = 20; + +const W2L_DEFAULT_TIME_LIMIT_SECONDS : i64 = 20; + +#[derive(Clone)] +pub struct PlanBuilder { + pub plan_name: String, + pub plan_category: PlanCategory, + pub cost_per_month_dollars: Option, + pub stripe_product_id: Option, + pub stripe_price_id: Option, + pub is_development_plan: bool, + + // ========== Features for TTS ========== + + pub tts_base_priority_level: u8, + pub tts_max_duration: Duration, + pub tts_can_generate_mp3: bool, + pub tts_can_upload_private_models: bool, + pub tts_can_share_private_models: bool, + + // ========== Features for Voice Conversion ========== + + pub vc_max_concurrent_models: u32, + + pub vc_pre_recorded_time_limit: Duration, + pub vc_pre_recorded_time_is_unlimited: bool, + + pub vc_real_time_time_limit: Duration, + pub vc_real_time_time_is_unlimited: bool, + + // ========== Features for W2L ========== + + pub w2l_max_duration: Duration, + pub w2l_can_turn_off_watermark: bool, +} + +impl PlanBuilder { + pub fn new(plan_name: &str) -> Self { + // NB: Not using default() since that seems dangerous when we want to be explicit. + PlanBuilder { + plan_name : plan_name.to_string(), + plan_category : PlanCategory::Free, + cost_per_month_dollars: None, + stripe_product_id: None, + stripe_price_id: None, + is_development_plan: false, + + // TTS + tts_base_priority_level : TTS_DEFAULT_PRIORITY_LEVEL, + tts_max_duration: Duration::seconds(TTS_DEFAULT_DURATION_SECONDS), + tts_can_generate_mp3 : false, + tts_can_upload_private_models: false, + tts_can_share_private_models : false, + + // VC + vc_max_concurrent_models: VC_DEFAULT_MAX_CONCURRENT_MODELS, + vc_pre_recorded_time_limit: Duration::seconds(VC_DEFAULT_PRE_RECORDED_TIME_LIMIT_SECONDS), + vc_pre_recorded_time_is_unlimited: false, + vc_real_time_time_limit: Duration::seconds(VC_DEFAULT_REAL_TIME_TIME_LIMIT_SECONDS), + vc_real_time_time_is_unlimited: false, + + // W2L + w2l_max_duration: Duration::seconds(W2L_DEFAULT_TIME_LIMIT_SECONDS), + w2l_can_turn_off_watermark: false + } + } + + pub fn build(&self) -> Plan { + Plan::from_builder(self) + } + + pub fn plan_category(mut self, value: PlanCategory) -> Self { + self.plan_category = value; + self + } + + pub fn cost_per_month_dollars(mut self, value: u32) -> Self { + self.cost_per_month_dollars = Some(value); + self + } + + pub fn stripe_product_id(mut self, value: &str) -> Self { + self.stripe_product_id = Some(value.to_string()); + self + } + + pub fn stripe_price_id(mut self, value: &str) -> Self { + self.stripe_price_id = Some(value.to_string()); + self + } + + pub fn is_development_plan(mut self, value: bool) -> Self { + self.is_development_plan = value; + self + } + + pub fn tts_base_priority_level(mut self, value: u8) -> Self { + self.tts_base_priority_level = value; + self + } + + pub fn tts_max_duration_seconds(mut self, value: i64) -> Self { + self.tts_max_duration = Duration::seconds(value); + self + } + + pub fn tts_can_generate_mp3(mut self, value: bool) -> Self { + self.tts_can_generate_mp3 = value; + self + } + + pub fn tts_can_upload_private_models(mut self, value: bool) -> Self { + self.tts_can_upload_private_models = value; + self + } + + pub fn tts_can_share_private_models(mut self, value: bool) -> Self { + self.tts_can_share_private_models = value; + self + } + + pub fn vc_max_concurrent_models(mut self, value: u32) -> Self { + self.vc_max_concurrent_models = value; + self + } + + pub fn vc_pre_recorded_time_limit_seconds(mut self, value: i64) -> Self { + self.vc_pre_recorded_time_limit = Duration::seconds(value); + self + } + + pub fn vc_pre_recorded_time_is_unlimited(mut self, value: bool) -> Self { + self.vc_pre_recorded_time_is_unlimited = value; + if value { + self.vc_pre_recorded_time_limit = Duration::seconds(60 * 60 * 24 * 7); + } + self + } + + pub fn vc_real_time_time_limit_seconds(mut self, value: i64) -> Self { + self.vc_real_time_time_limit = Duration::seconds(value); + self + } + + pub fn vc_real_time_time_is_unlimited(mut self, value: bool) -> Self { + self.vc_real_time_time_is_unlimited = value; + if value { + self.vc_real_time_time_limit = Duration::seconds(60 * 60 * 24 * 7); + } + self + } + + pub fn w2l_time_limit_seconds(mut self, value: i64) -> Self { + self.w2l_max_duration = Duration::seconds(value); + self + } + + pub fn w2l_can_turn_off_watermark(mut self, value: bool) -> Self { + self.w2l_can_turn_off_watermark = value; + self + } +} diff --git a/crates/service/storyteller_web/src/configs/plans/plan_category.rs b/crates/service/storyteller_web/src/configs/plans/plan_category.rs new file mode 100644 index 0000000000..59f0e1e31d --- /dev/null +++ b/crates/service/storyteller_web/src/configs/plans/plan_category.rs @@ -0,0 +1,11 @@ + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum PlanCategory { + /// Free plans cost nothing and are the lowest tiers of service. + Free, + /// Certain users will get premium features for free by contributing to FakeYou. + LoyaltyReward, + /// Paid plans come with various variable premium perks according to plan level. + Paid, +} + diff --git a/crates/service/storyteller_web/src/configs/plans/plan_list.rs b/crates/service/storyteller_web/src/configs/plans/plan_list.rs new file mode 100644 index 0000000000..8aa715edb7 --- /dev/null +++ b/crates/service/storyteller_web/src/configs/plans/plan_list.rs @@ -0,0 +1,64 @@ +use std::collections::{HashMap, HashSet}; +use once_cell::sync::Lazy; +use tts_common::priority::{FAKEYOU_ANONYMOUS_PRIORITY_LEVEL, FAKEYOU_LOGGED_IN_PRIORITY_LEVEL}; +use crate::configs::plans::plan::Plan; +use crate::configs::plans::plan_builder::PlanBuilder; +use crate::configs::plans::plan_category::PlanCategory; + +pub static FREE_LOGGED_OUT_PLAN : Lazy = Lazy::new(|| { + PlanBuilder::new("free_logged_out") + .plan_category(PlanCategory::Free) + .tts_base_priority_level(FAKEYOU_ANONYMOUS_PRIORITY_LEVEL) + .build() +}); + +pub static FREE_LOGGED_IN_PLAN : Lazy = Lazy::new(|| { + PlanBuilder::new("free_logged_in") + .plan_category(PlanCategory::Free) + .tts_base_priority_level(FAKEYOU_LOGGED_IN_PRIORITY_LEVEL) + .build() +}); + +/// This plan is for users that create models for us but don't pay. +pub static LOYALTY_PLAN : Lazy = Lazy::new(|| { + PlanBuilder::new("loyalty_plan") + .plan_category(PlanCategory::LoyaltyReward) + .tts_base_priority_level(2) + .build() +}); + +pub static PREMIUM_PLANS : Lazy> = Lazy::new(|| { + let mut plans = HashSet::new(); + + // ========== English plans ========== + + plans.insert(PlanBuilder::new("fakeyou_en_basic") + .plan_category(PlanCategory::Paid) + .cost_per_month_dollars(5) + .tts_base_priority_level(10) + .build()); + + plans.insert(PlanBuilder::new("fakeyou_en_pro") + .plan_category(PlanCategory::Paid) + .cost_per_month_dollars(15) + .tts_base_priority_level(20) + .build()); + + + // ========== Spanish plans ========== + + plans.insert(PlanBuilder::new("fakeyou_es_basic") + .plan_category(PlanCategory::Paid) + .cost_per_month_dollars(3) + .tts_base_priority_level(5) + .build()); + + plans.insert(PlanBuilder::new("fakeyou_es_pro") + .plan_category(PlanCategory::Paid) + .cost_per_month_dollars(7) + .tts_base_priority_level(11) + .build()); + + plans +}); +