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:
HugoCasa
2023-11-15 19:36:17 +01:00
committed by GitHub
parent 53fa4b5e44
commit b649fae0b0
16 changed files with 371 additions and 86 deletions
@@ -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
]
},
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
]
},
+7 -5
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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
+102 -19
View File
@@ -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();
+22 -11
View File
@@ -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
+1 -1
View File
@@ -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)