From b649fae0b0e0509bb569b08dc2a7a8565cd46617 Mon Sep 17 00:00:00 2001 From: HugoCasa Date: Wed, 15 Nov 2023 19:36:17 +0100 Subject: [PATCH] feat: sync team plans (#2619) * feat: sync team plans * fix: migration * fix: remove sub id + set billing cycle * fix: msg * feat: add overquota state * fix: minor * fix: overall improvements * fix: sql prepare * fix: disable seats warning for enterprise * fix: use lazy static everywhere --- ...6dceb195df0fb43a8ac03bc8b35939fc941dd.json | 6 + ...ae97fb49b0080075fb01e0863d164ba9a5f13.json | 40 +++++ ...b6e80888e511fb4787cb1a7976878abed1d30.json | 22 +++ ...38a3209b811324dba4ac661938e0523210682.json | 28 ++++ ...73ad728f7f786c7f447df6cac3e65d5a53bf1.json | 6 + backend/Cargo.lock | 12 +- backend/Cargo.toml | 5 +- .../20231114161526_add_overquota.down.sql | 1 + .../20231114161526_add_overquota.up.sql | 2 + backend/windmill-api/openapi.yaml | 19 +++ backend/windmill-api/src/workspaces.rs | 121 +++++++++++--- backend/windmill-queue/src/jobs.rs | 33 ++-- .../components/settings/PremiumInfo.svelte | 150 +++++++++++++----- .../lib/components/sidebar/UserMenu.svelte | 6 +- frontend/src/lib/stores.ts | 2 +- .../src/routes/(root)/(logged)/+layout.svelte | 4 +- 16 files changed, 371 insertions(+), 86 deletions(-) create mode 100644 backend/.sqlx/query-4999995f4459da32a0e2fc7115eae97fb49b0080075fb01e0863d164ba9a5f13.json create mode 100644 backend/.sqlx/query-4c970f10d345bcdcf956dcbfa22b6e80888e511fb4787cb1a7976878abed1d30.json create mode 100644 backend/.sqlx/query-6e816fdba0d281d5147ddc5d4d438a3209b811324dba4ac661938e0523210682.json create mode 100644 backend/migrations/20231114161526_add_overquota.down.sql create mode 100644 backend/migrations/20231114161526_add_overquota.up.sql diff --git a/backend/.sqlx/query-1289e7278d2a289bfaa53f00e0b6dceb195df0fb43a8ac03bc8b35939fc941dd.json b/backend/.sqlx/query-1289e7278d2a289bfaa53f00e0b6dceb195df0fb43a8ac03bc8b35939fc941dd.json index fc01ba95e3..d5bfd9ba75 100644 --- a/backend/.sqlx/query-1289e7278d2a289bfaa53f00e0b6dceb195df0fb43a8ac03bc8b35939fc941dd.json +++ b/backend/.sqlx/query-1289e7278d2a289bfaa53f00e0b6dceb195df0fb43a8ac03bc8b35939fc941dd.json @@ -27,6 +27,11 @@ "ordinal": 4, "name": "premium", "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "is_overquota", + "type_info": "Bool" } ], "parameters": { @@ -40,6 +45,7 @@ false, false, false, + false, false ] }, diff --git a/backend/.sqlx/query-4999995f4459da32a0e2fc7115eae97fb49b0080075fb01e0863d164ba9a5f13.json b/backend/.sqlx/query-4999995f4459da32a0e2fc7115eae97fb49b0080075fb01e0863d164ba9a5f13.json new file mode 100644 index 0000000000..6829a3d690 --- /dev/null +++ b/backend/.sqlx/query-4999995f4459da32a0e2fc7115eae97fb49b0080075fb01e0863d164ba9a5f13.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT premium, usage.usage as \"usage?\", workspace_settings.customer_id, workspace_settings.plan FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "premium", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "usage?", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "customer_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "plan", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "4999995f4459da32a0e2fc7115eae97fb49b0080075fb01e0863d164ba9a5f13" +} diff --git a/backend/.sqlx/query-4c970f10d345bcdcf956dcbfa22b6e80888e511fb4787cb1a7976878abed1d30.json b/backend/.sqlx/query-4c970f10d345bcdcf956dcbfa22b6e80888e511fb4787cb1a7976878abed1d30.json new file mode 100644 index 0000000000..26894a04ef --- /dev/null +++ b/backend/.sqlx/query-4c970f10d345bcdcf956dcbfa22b6e80888e511fb4787cb1a7976878abed1d30.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT premium FROM workspace WHERE workspace.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "premium", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4c970f10d345bcdcf956dcbfa22b6e80888e511fb4787cb1a7976878abed1d30" +} diff --git a/backend/.sqlx/query-6e816fdba0d281d5147ddc5d4d438a3209b811324dba4ac661938e0523210682.json b/backend/.sqlx/query-6e816fdba0d281d5147ddc5d4d438a3209b811324dba4ac661938e0523210682.json new file mode 100644 index 0000000000..779d8e3c93 --- /dev/null +++ b/backend/.sqlx/query-6e816fdba0d281d5147ddc5d4d438a3209b811324dba4ac661938e0523210682.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT premium, is_overquota FROM workspace WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "premium", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "is_overquota", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "6e816fdba0d281d5147ddc5d4d438a3209b811324dba4ac661938e0523210682" +} diff --git a/backend/.sqlx/query-f7f2598ac824e9b5719b96b1d1873ad728f7f786c7f447df6cac3e65d5a53bf1.json b/backend/.sqlx/query-f7f2598ac824e9b5719b96b1d1873ad728f7f786c7f447df6cac3e65d5a53bf1.json index 33bc8f1c2d..05fd1b0da6 100644 --- a/backend/.sqlx/query-f7f2598ac824e9b5719b96b1d1873ad728f7f786c7f447df6cac3e65d5a53bf1.json +++ b/backend/.sqlx/query-f7f2598ac824e9b5719b96b1d1873ad728f7f786c7f447df6cac3e65d5a53bf1.json @@ -27,6 +27,11 @@ "ordinal": 4, "name": "premium", "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "is_overquota", + "type_info": "Bool" } ], "parameters": { @@ -39,6 +44,7 @@ false, false, false, + false, false ] }, diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 668ce85e51..706688ee84 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -405,11 +405,12 @@ dependencies = [ [[package]] name = "async-stripe" -version = "0.14.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71b85eb331f5c2b6ae4ff00aabd20a8a21de88b9890c58579616bf760570b207" +checksum = "e1b39347f478f57a40cbaf82cbaebb4a350acc757cda30fc7ff88707a11d1d81" dependencies = [ "chrono", + "futures-util", "hex", "hmac", "http-types", @@ -417,7 +418,8 @@ dependencies = [ "hyper-tls", "serde", "serde_json", - "serde_qs 0.9.2", + "serde_path_to_error", + "serde_qs 0.10.1", "sha2 0.10.8", "smart-default", "smol_str", @@ -5729,9 +5731,9 @@ dependencies = [ [[package]] name = "serde_qs" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af4cee6cd4b23b45e6709150d1e9af5c748131de7e3316a7c2b3008051ed725" +checksum = "8cac3f1e2ca2fe333923a1ae72caca910b98ed0630bb35ef6f8c8517d6e81afa" dependencies = [ "percent-encoding", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 36bb44b9af..9ed5424ce5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -172,9 +172,10 @@ serde_derive = "1.0.147" const_format = { version = "0.2", features = ["rust_1_64", "rust_1_51"] } dyn-iter = "0.2.0" rsa = "0.7.2" -async-stripe = { version = "0.14", features = [ +async-stripe = { version = "0.25.2", features = [ "runtime-tokio-hyper", "checkout", + "billing", ] } async_zip = { version = "0.0.11", features = ["full"] } once_cell = "1.17.1" @@ -203,4 +204,4 @@ tokenizers = "0.14.1" candle-core = "0.3.0" candle-transformers = "0.3.0" candle-nn = "0.3.0" -tiberius = { version = "0.12.2", default-features = false, features = ["rustls", "tds73", "chrono"] } \ No newline at end of file +tiberius = { version = "0.12.2", default-features = false, features = ["rustls", "tds73", "chrono"] } diff --git a/backend/migrations/20231114161526_add_overquota.down.sql b/backend/migrations/20231114161526_add_overquota.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20231114161526_add_overquota.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20231114161526_add_overquota.up.sql b/backend/migrations/20231114161526_add_overquota.up.sql new file mode 100644 index 0000000000..d0950277b0 --- /dev/null +++ b/backend/migrations/20231114161526_add_overquota.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +alter table workspace add column is_overquota boolean not null default false; \ No newline at end of file diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index df99e1a496..4a1c85d072 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1199,6 +1199,23 @@ paths: deploy_to: type: string + /w/{workspace}/workspaces/is_premium: + get: + summary: get if workspace is premium + operationId: getIsPremium + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + + responses: + "200": + description: status + content: + application/json: + schema: + type: boolean + /w/{workspace}/workspaces/premium_info: get: summary: get premium info @@ -1220,6 +1237,8 @@ paths: type: boolean usage: type: number + seats: + type: number required: - premium diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 2ae4d106b9..eaa8ed6649 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -57,6 +57,7 @@ use sqlx::{FromRow, Postgres, Transaction}; use tempfile::TempDir; use tokio::fs::File; use tokio_util::io::ReaderStream; +use chrono::{TimeZone, Datelike}; pub fn workspaced_service() -> Router { let router = Router::new() @@ -74,6 +75,7 @@ pub fn workspaced_service() -> Router { .route("/edit_auto_invite", post(edit_auto_invite)) .route("/edit_deploy_to", post(edit_deploy_to)) .route("/tarball", get(tarball_workspace)) + .route("/is_premium", get(is_premium)) .route("/premium_info", get(premium_info)) .route("/edit_copilot_config", post(edit_copilot_config)) .route("/get_copilot_info", get(get_copilot_info) ) @@ -81,7 +83,7 @@ pub fn workspaced_service() -> Router { #[cfg(feature = "enterprise")] { - if std::env::var("STRIPE_KEY").is_err() { + if STRIPE_KEY.is_none() { return router; } else { tracing::info!("stripe enabled"); @@ -108,6 +110,11 @@ pub fn global_service() -> Router { .route("/delete/:workspace", delete(delete_workspace)) } +lazy_static::lazy_static! { + pub static ref STRIPE_KEY: Option = std::env::var("STRIPE_KEY").ok(); +} + + #[derive(FromRow, Serialize)] struct Workspace { id: String, @@ -115,6 +122,7 @@ struct Workspace { owner: String, deleted: bool, premium: bool, + is_overquota: bool, } #[derive(FromRow, Serialize, Debug)] @@ -268,10 +276,30 @@ async fn list_pending_invites( Ok(Json(rows)) } -#[derive(Serialize, FromRow)] +async fn is_premium( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + let mut tx = db.begin().await?; + let row = sqlx::query_scalar!( + "SELECT premium FROM workspace WHERE workspace.id = $1", + &w_id + + ) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(row)) +} + + +#[derive(Serialize)] pub struct PremiumWorkspaceInfo { pub premium: bool, pub usage: Option, + pub seats: Option, } async fn premium_info( authed: ApiAuthed, @@ -280,20 +308,43 @@ async fn premium_info( ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; - let row = sqlx::query_as::<_, PremiumWorkspaceInfo>( - "SELECT premium, usage.usage FROM workspace LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1", + let row = sqlx::query!( + r#"SELECT premium, usage.usage as "usage?", workspace_settings.customer_id, workspace_settings.plan FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1"#, + &w_id ) - .bind(w_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok(Json(row)) + let mut result = PremiumWorkspaceInfo { + premium: row.premium, + usage: row.usage, + seats: None, + }; + if row.premium && row.plan == Some("team".to_string()) { + let customer_id = row.customer_id.ok_or(Error::InternalErr(format!("no customer id for workspace {}", w_id)))?; + let client = stripe::Client::new(STRIPE_KEY.clone().ok_or(Error::InternalErr(format!("stripe key not set")))?); + let customer_id = stripe::CustomerId::from_str(&customer_id).map_err(to_anyhow)?; + let subscriptions = stripe::Subscription::list( + &client, + &stripe::ListSubscriptions { customer: Some(customer_id.clone()), limit: Some(1), ..Default::default() }, + ).await.map_err(to_anyhow)?; + if subscriptions.data.len() > 1 { + return Err(Error::InternalErr(format!("multiple subscriptions for customer {}, please contact us at ccontact@windmill.dev", customer_id))); + } + let subscription = subscriptions.data.get(0).ok_or_else(|| Error::InternalErr(format!("no subscription for customer {}", customer_id)))?; + result.seats = subscription.items.data.iter().filter_map(|item|{ + item.price.clone().map(|p| p.metadata.map(|m| m.get("plan").filter(|plan| plan == &"team").map(|_| item.quantity.map(|x| x as i32)))).flatten().flatten().flatten() + }).collect::>().get(0).copied(); + } + + Ok(Json(result)) } #[cfg(feature = "enterprise")] #[derive(Deserialize)] struct PlanQuery { plan: String, + seats: Option, } #[cfg(feature = "enterprise")] @@ -301,38 +352,70 @@ async fn stripe_checkout( authed: ApiAuthed, Path(w_id): Path, Query(plan): Query, + Extension(db): Extension, ) -> Result { // #[cfg(feature = "enterprise")] { require_admin(authed.is_admin, &authed.username)?; - - let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY")); + let client = stripe::Client::new(STRIPE_KEY.clone().ok_or(Error::InternalErr(format!("stripe key not set")))?); let base_url = BASE_URL.read().await.clone(); let success_rd = format!("{}/workspace_settings/checkout?success=true", base_url); let failure_rd = format!("{}/workspace_settings/checkout?success=false", base_url); let checkout_session = { - let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd); + let mut params = stripe::CreateCheckoutSession::new(&success_rd); params.mode = Some(stripe::CheckoutSessionMode::Subscription); + params.cancel_url = Some(&failure_rd); params.line_items = match plan.plan.as_str() { "team" => Some(vec![ stripe::CreateCheckoutSessionLineItems { - quantity: Some(1), + quantity: Some(plan.seats.unwrap_or(1) as u64), price: Some("price_1NCNOgGU3NdFi9eLuG4fZuEP".to_string()), - ..Default::default() - }, - stripe::CreateCheckoutSessionLineItems { - quantity: None, - price: Some("price_1NCNCpGU3NdFi9eLbiE6Ca42".to_string()), + adjustable_quantity: Some(stripe::CreateCheckoutSessionLineItemsAdjustableQuantity { + enabled: true, + minimum: Some(1), + ..Default::default() + }), ..Default::default() }, ]), _ => Err(Error::BadRequest("invalid plan".to_string()))?, }; - params.customer_email = Some(&authed.email); params.client_reference_id = Some(&w_id); + let customer_id = sqlx::query_scalar!( + "SELECT customer_id FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_one(&db) + .await?; + + match customer_id { + Some(customer_id) => { + params.customer = Some(CustomerId::from_str(&customer_id).map_err(to_anyhow)?) + } + _ => params.customer_email = Some(&authed.email), + } + + params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData { + metadata: { + let mut map = std::collections::HashMap::new(); + map.insert("workspace_id".to_string(), w_id.clone()); + Some(map) + }, + billing_cycle_anchor: Some({ + // first of the next month (and possibly next year) at noon UTC + let now = Utc::now(); + let date = if now.month() == 12 { + Utc.with_ymd_and_hms(now.year() + 1, 1, 1, 12, 0, 0).single().unwrap() + } else { + Utc.with_ymd_and_hms(now.year(), now.month() + 1, 1, 12, 0, 0).single().unwrap() + }; + date.timestamp() + }), + ..Default::default() + }); + stripe::CheckoutSession::create(&client, params) - .await - .unwrap() + .await.map_err(to_anyhow)? }; let uri = checkout_session .url @@ -355,7 +438,7 @@ async fn stripe_portal( .fetch_one(&db) .await? .ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?; - let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY")); + let client = stripe::Client::new(STRIPE_KEY.clone().ok_or(Error::InternalErr(format!("stripe key not set")))?); let success_rd = format!("{}/workspace_settings?tab=premium", BASE_URL.read().await.clone()); let portal_session = { let customer_id = CustomerId::from_str(&customer_id).unwrap(); diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 2130ab4d99..8c291a764f 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -494,8 +494,8 @@ pub async fn add_completed_job< if *CLOUD_HOSTED && !is_flow && _duration > 1000 { let additional_usage = _duration / 1000; let w_id = &queued_job.workspace_id; - let premium_workspace = *windmill_common::worker::CLOUD_HOSTED - && sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", w_id) + let premium_workspace = + sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", w_id) .fetch_one(db) .await .map_err(|e| Error::InternalErr(format!("fetching if {w_id} is premium: {e}")))?; @@ -2088,13 +2088,19 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection ) -> Result<(Uuid, QueueTransaction<'c, R>), Error> { #[cfg(feature = "enterprise")] if *CLOUD_HOSTED { - let premium_workspace = *CLOUD_HOSTED - && sqlx::query_scalar!("SELECT premium FROM workspace WHERE id = $1", workspace_id) - .fetch_one(_db) - .await - .map_err(|e| { - Error::InternalErr(format!("fetching if {workspace_id} is premium: {e}")) - })?; + let row = sqlx::query!( + "SELECT premium, is_overquota FROM workspace WHERE id = $1", + workspace_id + ) + .fetch_one(_db) + .await + .map_err(|e| { + Error::InternalErr(format!( + "fetching if {workspace_id} is premium and overquota: {e}" + )) + })?; + let premium_workspace = row.premium; + let is_overquota = premium_workspace && row.is_overquota; // we track only non flow steps let usage = if !matches!( @@ -2112,7 +2118,7 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection .fetch_one(_db) .await .map_err(|e| Error::InternalErr(format!("updating usage: {e}")))? - } else if *CLOUD_HOSTED && !premium_workspace { + } else if !premium_workspace { sqlx::query_scalar!( " SELECT usage.usage + 1 FROM usage @@ -2129,7 +2135,7 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection 0 }; - if *CLOUD_HOSTED && !premium_workspace { + if is_overquota || !premium_workspace { let is_super_admin = sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", email) .fetch_optional(_db) @@ -2137,6 +2143,11 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection .unwrap_or(false); if !is_super_admin { + if is_overquota { + return Err(error::Error::BadRequest(format!( + "Workspace {workspace_id} is overquota. Please update your subscription in the customer portal to continue using Windmill." + ))); + } if usage > MAX_FREE_EXECS && !matches!(job_payload, JobPayload::Dependencies { .. }) && !matches!(job_payload, JobPayload::FlowDependencies { .. }) diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index d48b076c65..38da3a67ab 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -7,13 +7,25 @@ import { Button } from '../common' import Tooltip from '../Tooltip.svelte' import { Badge, ExternalLink } from 'lucide-svelte' + import { twMerge } from 'tailwind-merge' export let plan: string | undefined export let customer_id: string | undefined let users: User[] | undefined = undefined - let premium_info: { premium: boolean; usage?: number } | undefined = undefined + let premiumInfo: + | { + premium: boolean + usage: number + seats: number + authorNb: number + operatorNb: number + seatsFromUsers: number + seatsFromComps: number + usedSeats: number + } + | undefined = undefined const plans = { Free: [ 'Users use their individual global free-tier quotas when doing executions in this workspace', @@ -47,7 +59,22 @@ } async function loadPremiumInfo() { - premium_info = await WorkspaceService.getPremiumInfo({ workspace: $workspaceStore! }) + const info = await WorkspaceService.getPremiumInfo({ workspace: $workspaceStore! }) + const authorNb = users?.filter((x) => !x.operator)?.length ?? 0 + const operatorNb = users?.filter((x) => x.operator)?.length ?? 0 + const seatsFromUsers = Math.ceil(authorNb + operatorNb / 2) + const seatsFromComps = Math.ceil((info.usage ?? 0) / 10000) + const usedSeats = Math.max(seatsFromUsers, seatsFromComps) + premiumInfo = { + ...info, + usage: info.usage ?? 0, + seats: info.seats ?? 1, + authorNb, + operatorNb, + seatsFromUsers, + seatsFromComps, + usedSeats + } } @@ -68,37 +95,33 @@ {/if}
- {#if premium_info?.premium} + {#if premiumInfo?.premium}
{#if plan}
- Current plan: {capitalize(plan ?? 'free')} plan + Current plan: {capitalize(plan)} plan{plan === 'team' + ? `(${premiumInfo.seats} seat${premiumInfo.seats > 1 ? 's' : ''})` + : ''}
{:else}
Current plan: Free plan
{/if} {#if plan} - {@const team_factor = plan == 'team' ? 10 : 40} - {@const user_nb = users?.filter((x) => !x.operator)?.length ?? 0} - {@const operator_nb = users?.filter((x) => x.operator)?.length ?? 0} - {@const seats_from_users = Math.ceil(user_nb + operator_nb / 2)} - {@const seats_from_comps = Math.ceil((premium_info?.usage ?? 0) / 10000)} -
Authors - + Actual pricing is calculated on the MAXIMUM number of users in a given billing period, see the customer portal for more info. -
- {user_nb} +
+ {premiumInfo.authorNb}
@@ -111,48 +134,81 @@ -
- {operator_nb} +
+ {premiumInfo.operatorNb}
- Seats from authors + operators +
Minimum number of seats needed for users
- ceil({user_nb} + {operator_nb}/2) = {seats_from_users} + u = ceil({premiumInfo.authorNb} + {premiumInfo.operatorNb}/2) = {premiumInfo.seatsFromUsers}
Computations executed this month -
- {premium_info?.usage ?? 0} +
+ {premiumInfo.usage}
- Seats from computations +
Minimum number of seats needed for computations
- ceil({premium_info?.usage ?? 0} / 10 000) = {seats_from_comps} + c = ceil({premiumInfo.usage} / 10 000) = {premiumInfo.seatsFromComps}
- Total seats +
premiumInfo.seats + ? 'text-red-500' + : '' + )} + >Used seats Highest between seats from authors + operators and seats from computations + {plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats + ? ' > Paid seats' + : ''}
-
- max({seats_from_comps}, {seats_from_users}) * {team_factor} = ${Math.max( - seats_from_comps, - seats_from_users - ) * team_factor}/mo +
premiumInfo.seats + ? 'text-red-500' + : '' + )} + > + max(u, c) = max({premiumInfo.seatsFromUsers}, {premiumInfo.seatsFromComps}) = {premiumInfo.usedSeats}{premiumInfo.usedSeats > + premiumInfo.seats + ? ` > ${premiumInfo.seats}` + : ''}
+ + {#if plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats} +

You have exceeded your allowed number of seats, please update your plan in the + customer portal. +

+ {/if}
{/if}
@@ -178,13 +234,21 @@ {#if planTitle == 'Team'} {#if plan != 'team'}
- + {#if plan != 'enterprise'} + + {:else} +
+ Cancel your plan in the customer portal then upgrade to a team plan +
+ {/if}
{:else}
Workspace is on the team plan
@@ -197,16 +261,16 @@
{:else} -
Workspace is on enterprise plan
+
Workspace is on enterprise plan
+ {/if} + {:else if planTitle === 'Free'} + {#if plan} +
+ Cancel your plan in the customer portal to downgrade to the free plan +
+ {:else} + Workspace is on the free plan {/if} - {:else if !plan} - Workspace is on the free plan - {:else} -
- -
{/if}
{/each} diff --git a/frontend/src/lib/components/sidebar/UserMenu.svelte b/frontend/src/lib/components/sidebar/UserMenu.svelte index 80ac466e07..468f06da99 100644 --- a/frontend/src/lib/components/sidebar/UserMenu.svelte +++ b/frontend/src/lib/components/sidebar/UserMenu.svelte @@ -1,7 +1,7 @@