From 8e6c02d1b1b59d5d94d7c265db73982fbbf333bf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 22 Oct 2022 22:41:19 -0400 Subject: [PATCH] Premium plan definitions --- .env | 2 +- Cargo.lock | 64 ++++ crates/service/storyteller_web/Cargo.toml | 3 + .../storyteller_web/src/configs/plans/mod.rs | 1 - .../storyteller_web/src/configs/plans/plan.rs | 251 ++++++++++++++-- .../src/configs/plans/plan_builder.rs | 173 ----------- .../src/configs/plans/plan_list.rs | 274 +++++++++++++++++- 7 files changed, 559 insertions(+), 209 deletions(-) delete mode 100644 crates/service/storyteller_web/src/configs/plans/plan_builder.rs diff --git a/.env b/.env index 7b688fde09..0a994f7c1f 100644 --- a/.env +++ b/.env @@ -76,4 +76,4 @@ STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:12345/stripe/checkout/success STRIPE_CHECKOUT_CANCEL_URL=http://localhost:12345/stripe/checkout/cancel STRIPE_PUBLISHABLE_KEY=pk_test_51KZvWXEU5se17Mekx6e0gw32ovDF78k2eeaGctLIs8oQVxN6DerEp1BDqh6gLMYKli4VsTZoVGHoxq5RmwKMlxtP00r59UEEad STRIPE_SECRET_KEY=sk_test_51KZvWXEU5se17Mek11ZdnWr4frnyw7tWOPuvl3pRzL290zvkT6KqcbGUl3fcvc3hA8oH6lWXz2kGWXXUlNcCuDxB003uVT9JX0 -STRIPE_SECRET_WEBHOOK_SIGNING_KEY=whsec_40b60bd75768052532b63dbeefa7a1b163e182c3e657fb27fa906ef49a2cd59f \ No newline at end of file +STRIPE_SECRET_WEBHOOK_SIGNING_KEY=whsec_40b60bd75768052532b63dbeefa7a1b163e182c3e657fb27fa906ef49a2cd59f diff --git a/Cargo.lock b/Cargo.lock index eaae89d037..73fff97b2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2854,6 +2854,32 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "num" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4703ad64153382334aa8db57c637364c322d3372e097840c72000dabdcf6156e" +dependencies = [ + "num-bigint 0.1.44", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e63899ad0da84ce718c14936262a41cee2c79c981fc0a0e7c7beb47d5a07e8c1" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.4.6", + "rustc-serialize", +] + [[package]] name = "num-bigint" version = "0.3.2" @@ -2894,6 +2920,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b288631d7878aaf59442cffd36910ea604ecd7745c36054328595114001c9656" +dependencies = [ + "num-traits", + "rustc-serialize", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -2915,6 +2951,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee314c74bd753fc86b4780aa9475da469155f3848473a261d2d18e35245a784e" +dependencies = [ + "num-bigint 0.1.44", + "num-integer", + "num-traits", + "rustc-serialize", +] + [[package]] name = "num-traits" version = "0.2.14" @@ -3633,6 +3681,12 @@ dependencies = [ "url 2.2.2", ] +[[package]] +name = "rustc-serialize" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf128d1287d2ea9d80910b5f1120d0b8eede3fbf1abe91c40d39ea7d51e6fda" + [[package]] name = "rustc_version" version = "0.2.3" @@ -3998,6 +4052,15 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "speculoos" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bcff6a72e2ddfb09ce0064568857e70d3cf6f4342244ebb1f84fa6f89b16c26" +dependencies = [ + "num", +] + [[package]] name = "spin" version = "0.5.2" @@ -4252,6 +4315,7 @@ dependencies = [ "serde_derive", "serde_json", "sha2 0.9.5", + "speculoos", "sqlx", "storage_buckets_common", "subprocess", diff --git a/crates/service/storyteller_web/Cargo.toml b/crates/service/storyteller_web/Cargo.toml index be9db2b872..6c6d859581 100644 --- a/crates/service/storyteller_web/Cargo.toml +++ b/crates/service/storyteller_web/Cargo.toml @@ -143,3 +143,6 @@ pulldown-cmark = "0.8.0" [build-dependencies] # NB: Only using diesel for migrations support diesel = { version = "1.4.6", features = ["mysql"] } + +[dev-dependencies] +speculoos = "0.9.0" # Fluent assertions diff --git a/crates/service/storyteller_web/src/configs/plans/mod.rs b/crates/service/storyteller_web/src/configs/plans/mod.rs index f4fc64494f..378000678e 100644 --- a/crates/service/storyteller_web/src/configs/plans/mod.rs +++ b/crates/service/storyteller_web/src/configs/plans/mod.rs @@ -1,4 +1,3 @@ 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 index ad05e9fb2c..61bd3e689e 100644 --- a/crates/service/storyteller_web/src/configs/plans/plan.rs +++ b/crates/service/storyteller_web/src/configs/plans/plan.rs @@ -1,14 +1,24 @@ use chrono::Duration; -use crate::configs::plans::plan_builder::PlanBuilder; 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; + /// 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, + /// Name of the plan in URL-friendly "slug" form (human readable, no spaces, underscores), + /// eg. "plan_1", "en_basic", etc. + plan_slug: String, /// Whether the plan is free, paid, or loyalty-based plan_category: PlanCategory, @@ -29,6 +39,93 @@ pub struct Plan { // ========== Features for TTS ========== + tts_base_priority_level: u8, + tts_max_duration: Duration, + tts_can_generate_mp3: bool, // TODO: This feature doesn't exist yet. + 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_slug: builder.plan_slug.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, + } + } + + pub fn plan_slug(&self) -> &str { + &self.plan_slug + } + + pub fn plan_category(&self) -> PlanCategory { + self.plan_category + } + + pub fn stripe_product_id(&self) -> Option<&str> { + self.stripe_product_id.as_deref() + } + + pub fn stripe_price_id(&self) -> Option<&str> { + self.stripe_price_id.as_deref() + } + + pub fn is_development_plan(&self) -> bool { + self.is_development_plan + } + + pub fn tts_base_priority_level(&self) -> u8 { + self.tts_base_priority_level + } + + pub fn tts_max_duration(&self) -> Duration { + self.tts_max_duration + } +} + +/// Builder pattern for Plans. +#[derive(Clone)] +pub struct PlanBuilder { + plan_slug: String, + plan_category: PlanCategory, + cost_per_month_dollars: Option, + stripe_product_id: Option, + stripe_price_id: Option, + is_development_plan: bool, + + // ========== Features for TTS ========== + tts_base_priority_level: u8, tts_max_duration: Duration, tts_can_generate_mp3: bool, @@ -51,27 +148,131 @@ pub struct Plan { 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, +impl PlanBuilder { + pub fn new(plan_slug: &str) -> Self { + // NB: Not using default() since that seems dangerous when we want to be explicit. + PlanBuilder { + plan_slug: plan_slug.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 } } -} \ No newline at end of file + + 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 + } +} + +// TODO: Add tests diff --git a/crates/service/storyteller_web/src/configs/plans/plan_builder.rs b/crates/service/storyteller_web/src/configs/plans/plan_builder.rs deleted file mode 100644 index 80d95945af..0000000000 --- a/crates/service/storyteller_web/src/configs/plans/plan_builder.rs +++ /dev/null @@ -1,173 +0,0 @@ -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_list.rs b/crates/service/storyteller_web/src/configs/plans/plan_list.rs index 8aa715edb7..d705e5c406 100644 --- a/crates/service/storyteller_web/src/configs/plans/plan_list.rs +++ b/crates/service/storyteller_web/src/configs/plans/plan_list.rs @@ -1,10 +1,12 @@ -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::PlanBuilder; use crate::configs::plans::plan_category::PlanCategory; +use once_cell::sync::Lazy; +use std::collections::{HashMap, HashSet}; +use tts_common::priority::{FAKEYOU_ANONYMOUS_PRIORITY_LEVEL, FAKEYOU_LOGGED_IN_PRIORITY_LEVEL}; +/// The plan for free logged-out users. +/// This is declared at the top level because it may need to be referenced directly. pub static FREE_LOGGED_OUT_PLAN : Lazy = Lazy::new(|| { PlanBuilder::new("free_logged_out") .plan_category(PlanCategory::Free) @@ -12,6 +14,17 @@ pub static FREE_LOGGED_OUT_PLAN : Lazy = Lazy::new(|| { .build() }); +/// A plan for free logged-out users on their first few attempts at using the website. +/// This is declared at the top level because it may need to be referenced directly. +pub static FREE_LOGGED_OUT_FIRST_TRY_PLAN : Lazy = Lazy::new(|| { + PlanBuilder::new("free_logged_out_first_try") + .plan_category(PlanCategory::Free) + .tts_base_priority_level(FAKEYOU_LOGGED_IN_PRIORITY_LEVEL) // NB: Same as logged-in free users. + .build() +}); + +/// The plan for free logged-in users. +/// This is declared at the top level because it may need to be referenced directly. pub static FREE_LOGGED_IN_PLAN : Lazy = Lazy::new(|| { PlanBuilder::new("free_logged_in") .plan_category(PlanCategory::Free) @@ -20,45 +33,288 @@ pub static FREE_LOGGED_IN_PLAN : Lazy = Lazy::new(|| { }); /// This plan is for users that create models for us but don't pay. +/// This is declared at the top level because it may need to be referenced directly. pub static LOYALTY_PLAN : Lazy = Lazy::new(|| { PlanBuilder::new("loyalty_plan") .plan_category(PlanCategory::LoyaltyReward) .tts_base_priority_level(2) + .tts_max_duration_seconds(30) .build() }); +// TODO: Add by-[stripe-product] lookup HashSets. + +pub static DEVELOPMENT_PLANS : Lazy> = Lazy::new(|| { + let mut plans = HashSet::new(); + + // ========== English plans ========== + + plans.insert(PlanBuilder::new("development_fakeyou_en_plus") + .is_development_plan(true) + .plan_category(PlanCategory::Paid) + .stripe_product_id("prod_MMxi2J5y69VPbO") + .stripe_price_id("price_1LeDnKEU5se17MekVr1iYYNf") + .cost_per_month_dollars(5) + .tts_base_priority_level(10) + .tts_max_duration_seconds(30) + .build()); + + plans.insert(PlanBuilder::new("development_fakeyou_en_pro") + .is_development_plan(true) + .plan_category(PlanCategory::Paid) + .stripe_product_id("prod_MScAZa5uk5TfDY") + .stripe_price_id("price_1LjgwIEU5se17MekzQZUHl9W") + .cost_per_month_dollars(15) + .tts_base_priority_level(20) + .tts_max_duration_seconds(120) + .build()); + + plans +}); + +/// All premium / paid-for plans in the system. pub static PREMIUM_PLANS : Lazy> = Lazy::new(|| { let mut plans = HashSet::new(); // ========== English plans ========== - plans.insert(PlanBuilder::new("fakeyou_en_basic") + plans.insert(PlanBuilder::new("fakeyou_en_plus") .plan_category(PlanCategory::Paid) - .cost_per_month_dollars(5) + .cost_per_month_dollars(7) .tts_base_priority_level(10) + .tts_max_duration_seconds(30) .build()); plans.insert(PlanBuilder::new("fakeyou_en_pro") .plan_category(PlanCategory::Paid) .cost_per_month_dollars(15) .tts_base_priority_level(20) + .tts_max_duration_seconds(120) .build()); - // ========== Spanish plans ========== - plans.insert(PlanBuilder::new("fakeyou_es_basic") + plans.insert(PlanBuilder::new("fakeyou_es_plus") .plan_category(PlanCategory::Paid) .cost_per_month_dollars(3) .tts_base_priority_level(5) + .tts_max_duration_seconds(30) .build()); plans.insert(PlanBuilder::new("fakeyou_es_pro") .plan_category(PlanCategory::Paid) .cost_per_month_dollars(7) - .tts_base_priority_level(11) + .tts_base_priority_level(15) + .tts_max_duration_seconds(45) .build()); plans }); +/// Every single plan in the system: free, paid, loyalty-based, and Stripe development plans. +pub static ALL_PLANS_BY_SLUG : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + fn add_plan(plans: &mut HashMap, plan: &Plan) { + plans.insert(plan.plan_slug().to_string(), plan.clone()); + } + + add_plan(&mut plans, &FREE_LOGGED_OUT_PLAN); + add_plan(&mut plans, &FREE_LOGGED_OUT_FIRST_TRY_PLAN); + add_plan(&mut plans, &FREE_LOGGED_IN_PLAN); + add_plan(&mut plans, &LOYALTY_PLAN); + + PREMIUM_PLANS.iter().for_each(|plan| add_plan(&mut plans, plan)); + DEVELOPMENT_PLANS .iter().for_each(|plan| add_plan(&mut plans, plan)); + + plans +}); + +/// Every Stripe premium product by its Stripe product id. +pub static PLANS_BY_STRIPE_PRODUCT_ID : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + ALL_PLANS_BY_SLUG.values() + .for_each(|plan| { + if let Some(stripe_product_id) = plan.stripe_product_id() { + plans.insert(stripe_product_id.to_string(), plan.clone()); + } + }); + + plans +}); + +/// Every Stripe premium product by its Stripe price id. +pub static PLANS_BY_STRIPE_PRICE_ID : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + ALL_PLANS_BY_SLUG.values() + .for_each(|plan| { + if let Some(stripe_price_id) = plan.stripe_price_id() { + plans.insert(stripe_price_id.to_string(), plan.clone()); + } + }); + + plans +}); + +// TODO: Add tests to assert our expectations of each plan. + +#[cfg(test)] +mod test { + use crate::configs::plans::plan::Plan; + use crate::configs::plans::plan_category::PlanCategory; + use crate::configs::plans::plan_list::{ALL_PLANS_BY_SLUG, DEVELOPMENT_PLANS, PLANS_BY_STRIPE_PRICE_ID, PLANS_BY_STRIPE_PRODUCT_ID, PREMIUM_PLANS}; + use speculoos::prelude::*; + + // NB: We're being extremely careful in this test and all those that follow, essentially + // making ourselves check twice when we make additions, removals, or other changes. + #[test] + fn test_number_of_plans_is_expected() { + assert_eq!(10, ALL_PLANS_BY_SLUG.len()); + } + + #[test] + fn test_number_of_production_plans_is_expected() { + let production_plans = ALL_PLANS_BY_SLUG.values() + .filter(|plan| !plan.is_development_plan()) + .collect::>(); + + assert_eq!(8, production_plans.len()); + } + + #[test] + fn test_number_of_development_plans_is_expected() { + let development_plans = ALL_PLANS_BY_SLUG.values() + .filter(|plan| plan.is_development_plan()) + .collect::>(); + + assert_eq!(2, development_plans.len()); + } + + #[test] + fn test_plans_by_stripe_product_id_are_expected() { + let product_ids = PLANS_BY_STRIPE_PRODUCT_ID.keys() + .map(|product_id| product_id.to_string()) + .collect::>(); + + let expected_product_ids = vec![ + "prod_MMxi2J5y69VPbO".to_string(), // NB: Development + "prod_MScAZa5uk5TfDY".to_string(), // NB: Development + ]; + + assert_that(&product_ids).contains_all_of(&expected_product_ids.iter()); + assert_eq!(product_ids.len(), expected_product_ids.len()); + } + + #[test] + fn test_plans_by_stripe_price_id_are_expected() { + let price_ids = PLANS_BY_STRIPE_PRICE_ID.keys() + .map(|price_id| price_id.to_string()) + .collect::>(); + + let expected_price_ids = vec![ + "price_1LjgwIEU5se17MekzQZUHl9W".to_string(), // NB: Development + "price_1LeDnKEU5se17MekVr1iYYNf".to_string(), // NB: Development + ]; + + assert_that(&price_ids).contains_all_of(&expected_price_ids.iter()); + assert_eq!(price_ids.len(), expected_price_ids.len()); + } + + #[test] + fn test_tts_base_priority_levels_are_expected() { + // Free + assert_eq!(0, ALL_PLANS_BY_SLUG.get("free_logged_out").unwrap().tts_base_priority_level()); + assert_eq!(1, ALL_PLANS_BY_SLUG.get("free_logged_out_first_try").unwrap().tts_base_priority_level()); + assert_eq!(1, ALL_PLANS_BY_SLUG.get("free_logged_in").unwrap().tts_base_priority_level()); + + // Loyalty-based + assert_eq!(2, ALL_PLANS_BY_SLUG.get("loyalty_plan").unwrap().tts_base_priority_level()); + + // Premium + assert_eq!(5, ALL_PLANS_BY_SLUG.get("fakeyou_es_plus").unwrap().tts_base_priority_level()); + assert_eq!(10, ALL_PLANS_BY_SLUG.get("fakeyou_en_plus").unwrap().tts_base_priority_level()); + assert_eq!(15, ALL_PLANS_BY_SLUG.get("fakeyou_es_pro").unwrap().tts_base_priority_level()); + assert_eq!(20, ALL_PLANS_BY_SLUG.get("fakeyou_en_pro").unwrap().tts_base_priority_level()); + } + + #[test] + fn test_tts_max_durations_are_expected() { + // Free + assert_eq!(12, ALL_PLANS_BY_SLUG.get("free_logged_out").unwrap().tts_max_duration().num_seconds()); + assert_eq!(12, ALL_PLANS_BY_SLUG.get("free_logged_out_first_try").unwrap().tts_max_duration().num_seconds()); + assert_eq!(12, ALL_PLANS_BY_SLUG.get("free_logged_in").unwrap().tts_max_duration().num_seconds()); + + // Loyalty-based + assert_eq!(30, ALL_PLANS_BY_SLUG.get("loyalty_plan").unwrap().tts_max_duration().num_seconds()); + + // Premium + assert_eq!(30, ALL_PLANS_BY_SLUG.get("fakeyou_es_plus").unwrap().tts_max_duration().num_seconds()); + assert_eq!(30, ALL_PLANS_BY_SLUG.get("fakeyou_en_plus").unwrap().tts_max_duration().num_seconds()); + assert_eq!(45, ALL_PLANS_BY_SLUG.get("fakeyou_es_pro").unwrap().tts_max_duration().num_seconds()); + assert_eq!(120, ALL_PLANS_BY_SLUG.get("fakeyou_en_pro").unwrap().tts_max_duration().num_seconds()); + } + + // The following tests should not need to change much + + #[test] + fn test_assert_all_premium_plans_are_paid() { + let mut test_count = 0; + + PREMIUM_PLANS.iter().for_each(|plan| { + assert_eq!(plan.plan_category(), PlanCategory::Paid); + test_count += 1; + }); + + assert!(test_count > 1); + } + + #[test] + fn test_assert_all_development_stripe_plans_are_marked_development() { + let mut test_count = 0; + + DEVELOPMENT_PLANS.iter().for_each(|plan| { + assert!(plan.is_development_plan()); + test_count += 1; + }); + + assert!(test_count > 1); + } + + #[test] + fn test_assert_correct_key_for_plans_indexed_by_slug() { + let mut test_count = 0; + + ALL_PLANS_BY_SLUG.iter().for_each(|(slug, plan)| { + assert_eq!(slug, plan.plan_slug()); + test_count += 1; + }); + + assert!(test_count > 1); + } + + #[test] + fn test_assert_correct_key_for_plans_indexed_by_product_id() { + let mut test_count = 0; + + PLANS_BY_STRIPE_PRODUCT_ID.iter().for_each(|(product_id, plan)| { + assert_eq!(product_id, plan.stripe_product_id().unwrap()); + test_count += 1; + }); + + assert!(test_count > 1); + } + + #[test] + fn test_assert_correct_key_for_plans_indexed_by_price_id() { + let mut test_count = 0; + + PLANS_BY_STRIPE_PRICE_ID.iter().for_each(|(price_id, plan)| { + assert_eq!(price_id, plan.stripe_price_id().unwrap()); + test_count += 1; + }); + + assert!(test_count > 1); + } +}