mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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
This commit is contained in:
+6
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
+40
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+6
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+7
-5
@@ -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",
|
||||
|
||||
+3
-2
@@ -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"] }
|
||||
tiberius = { version = "0.12.2", default-features = false, features = ["rustls", "tds73", "chrono"] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add up migration script here
|
||||
alter table workspace add column is_overquota boolean not null default false;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<String> = 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<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<bool> {
|
||||
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<i32>,
|
||||
pub seats: Option<i32>,
|
||||
}
|
||||
async fn premium_info(
|
||||
authed: ApiAuthed,
|
||||
@@ -280,20 +308,43 @@ async fn premium_info(
|
||||
) -> JsonResult<PremiumWorkspaceInfo> {
|
||||
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::<Vec<_>>().get(0).copied();
|
||||
}
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Deserialize)]
|
||||
struct PlanQuery {
|
||||
plan: String,
|
||||
seats: Option<i32>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -301,38 +352,70 @@ async fn stripe_checkout(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Query(plan): Query<PlanQuery>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> Result<Redirect> {
|
||||
// #[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();
|
||||
|
||||
@@ -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 { .. })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,37 +95,33 @@
|
||||
{/if}
|
||||
|
||||
<div class="text-xs my-4">
|
||||
{#if premium_info?.premium}
|
||||
{#if premiumInfo?.premium}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if plan}
|
||||
<div class="text-base inline font-bold leading-8 mb-2">
|
||||
Current plan: {capitalize(plan ?? 'free')} plan
|
||||
Current plan: {capitalize(plan)} plan{plan === 'team'
|
||||
? `(${premiumInfo.seats} seat${premiumInfo.seats > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="inline text-lg font-bold">Current plan: Free plan</div>
|
||||
{/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)}
|
||||
|
||||
<div class="w-full">
|
||||
<DataTable>
|
||||
<tbody class="divide-y">
|
||||
<tr>
|
||||
<Cell first>
|
||||
Authors
|
||||
<Tooltip light>
|
||||
<Tooltip>
|
||||
Actual pricing is calculated on the MAXIMUM number of users in a given billing
|
||||
period, see the customer portal for more info.
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
{user_nb}
|
||||
<div class="text-base">
|
||||
{premiumInfo.authorNb}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
@@ -111,48 +134,81 @@
|
||||
</Tooltip>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
{operator_nb}
|
||||
<div class="text-base">
|
||||
{premiumInfo.operatorNb}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell first>Seats from authors + operators</Cell>
|
||||
<Cell first
|
||||
><div class="font-semibold">Minimum number of seats needed for users</div></Cell
|
||||
>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
ceil({user_nb} + {operator_nb}/2) = {seats_from_users}
|
||||
u = ceil({premiumInfo.authorNb} + {premiumInfo.operatorNb}/2) = {premiumInfo.seatsFromUsers}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell first>Computations executed this month</Cell>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
{premium_info?.usage ?? 0}
|
||||
<div class="text-base">
|
||||
{premiumInfo.usage}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell first>Seats from computations</Cell>
|
||||
<Cell first
|
||||
><div class="font-semibold">Minimum number of seats needed for computations</div
|
||||
></Cell
|
||||
>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
ceil({premium_info?.usage ?? 0} / 10 000) = {seats_from_comps}
|
||||
c = ceil({premiumInfo.usage} / 10 000) = {premiumInfo.seatsFromComps}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell first>Total seats</Cell>
|
||||
<Cell first
|
||||
><div
|
||||
class={twMerge(
|
||||
'font-semibold',
|
||||
plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
)}
|
||||
>Used seats <Tooltip
|
||||
>Highest between seats from authors + operators and seats from computations
|
||||
</Tooltip>{plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats
|
||||
? ' > Paid seats'
|
||||
: ''}</div
|
||||
></Cell
|
||||
>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
max({seats_from_comps}, {seats_from_users}) * {team_factor} = ${Math.max(
|
||||
seats_from_comps,
|
||||
seats_from_users
|
||||
) * team_factor}/mo
|
||||
<div
|
||||
class={twMerge(
|
||||
'text-base font-bold',
|
||||
plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
max(u, c) = max({premiumInfo.seatsFromUsers}, {premiumInfo.seatsFromComps}) = {premiumInfo.usedSeats}{premiumInfo.usedSeats >
|
||||
premiumInfo.seats
|
||||
? ` > ${premiumInfo.seats}`
|
||||
: ''}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
{#if plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats}
|
||||
<p class="text-red-500 mt-2 text-right text-base"
|
||||
>You have exceeded your allowed number of seats, please update your plan in the
|
||||
customer portal.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -178,13 +234,21 @@
|
||||
{#if planTitle == 'Team'}
|
||||
{#if plan != 'team'}
|
||||
<div class="mt-4 mx-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
color="dark"
|
||||
href="/api/w/{$workspaceStore}/workspaces/checkout?plan=team"
|
||||
>
|
||||
Upgrade to the Team plan</Button
|
||||
>
|
||||
{#if plan != 'enterprise'}
|
||||
<Button
|
||||
size="lg"
|
||||
color="dark"
|
||||
href="/api/w/{$workspaceStore}/workspaces/checkout?plan=team{premiumInfo?.usedSeats
|
||||
? `&seats=${premiumInfo.usedSeats}`
|
||||
: ''}"
|
||||
>
|
||||
Upgrade to the Team plan</Button
|
||||
>
|
||||
{:else}
|
||||
<div class="mx-auto font-semibold text-center">
|
||||
Cancel your plan in the customer portal then upgrade to a team plan
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto text-md font-semibold">Workspace is on the team plan</div>
|
||||
@@ -197,16 +261,16 @@
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto text-lg font-semibold">Workspace is on enterprise plan</div>
|
||||
<div class="mx-auto text-md font-semibold">Workspace is on enterprise plan</div>
|
||||
{/if}
|
||||
{:else if planTitle === 'Free'}
|
||||
{#if plan}
|
||||
<div class="mx-auto font-semibold text-center">
|
||||
Cancel your plan in the customer portal to downgrade to the free plan
|
||||
</div>
|
||||
{:else}
|
||||
<Badge class="mx-auto text-md font-semibold">Workspace is on the free plan</Badge>
|
||||
{/if}
|
||||
{:else if !plan}
|
||||
<Badge class="mx-auto text-lg font-semibold">Workspace is on the free plan</Badge>
|
||||
{:else}
|
||||
<div class="mt-4 w-full">
|
||||
<Button href="/api/w/{$workspaceStore}/workspaces/checkout" color="dark"
|
||||
>Upgrade to the {planTitle} plan
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { logout } from '$lib/logout'
|
||||
import { userStore, usersWorkspaceStore, usageStore, premiumStore } from '$lib/stores'
|
||||
import { userStore, usersWorkspaceStore, usageStore, isPremiumStore } from '$lib/stores'
|
||||
import Menu from '../common/menu/MenuV2.svelte'
|
||||
import { USER_SETTINGS_HASH } from './settings'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
@@ -100,8 +100,8 @@
|
||||
</MenuItem>
|
||||
</div>
|
||||
|
||||
{#if isCloudHosted() && $premiumStore}
|
||||
{#if !$premiumStore.premium}
|
||||
{#if isCloudHosted()}
|
||||
{#if !$isPremiumStore}
|
||||
<div class="py-1" role="none">
|
||||
<span class="text-secondary block w-full text-left px-4 py-2 text-sm"
|
||||
>{$usageStore}/1000 free-tier executions</span
|
||||
|
||||
@@ -32,7 +32,7 @@ export const workspaceStore = writable<string | undefined>(
|
||||
persistedWorkspace ? String(persistedWorkspace) : undefined
|
||||
)
|
||||
export const dbClockDrift = writable<number | undefined>(undefined)
|
||||
export const premiumStore = writable<{ premium: boolean; usage?: number }>({ premium: false })
|
||||
export const isPremiumStore = writable<boolean>(false)
|
||||
export const starStore = writable(1)
|
||||
export const usersWorkspaceStore = writable<UserWorkspaceList | undefined>(undefined)
|
||||
export const superadmin = writable<String | false | undefined>(undefined)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
|
||||
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
|
||||
import {
|
||||
premiumStore,
|
||||
isPremiumStore,
|
||||
starStore,
|
||||
superadmin,
|
||||
usageStore,
|
||||
@@ -70,7 +70,7 @@
|
||||
const user = await getUserExt(workspace)
|
||||
userStore.set(user)
|
||||
if (isCloudHosted() && user?.is_admin) {
|
||||
premiumStore.set(await WorkspaceService.getPremiumInfo({ workspace }))
|
||||
isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace }))
|
||||
}
|
||||
} else {
|
||||
userStore.set(undefined)
|
||||
|
||||
Reference in New Issue
Block a user