diff --git a/backend/migrations/20230119194229_customer_id.down.sql b/backend/migrations/20230119194229_customer_id.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20230119194229_customer_id.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20230119194229_customer_id.up.sql b/backend/migrations/20230119194229_customer_id.up.sql new file mode 100644 index 0000000000..74696214f6 --- /dev/null +++ b/backend/migrations/20230119194229_customer_id.up.sql @@ -0,0 +1,3 @@ +-- Add up migration script here +ALTER TABLE workspace_settings ADD COLUMN customer_id VARCHAR(100); +ALTER TABLE workspace_settings ADD COLUMN plan VARCHAR(40); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8681c299c7..d5be47cd7b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -879,6 +879,10 @@ paths: type: string auto_invite_operator: type: boolean + plan: + type: string + customer_id: + type: string /w/{workspace}/workspaces/premium_info: get: diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 5af235bab3..1ed96d88a9 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -6,7 +6,7 @@ * LICENSE-AGPL for a copy of the license. */ -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; use crate::{ db::{UserDB, DB}, @@ -20,13 +20,14 @@ use axum::{ body::StreamBody, extract::{Extension, Path, Query}, headers, - response::IntoResponse, + response::{IntoResponse, Redirect}, routing::{delete, get, post}, Json, Router, }; +use stripe::CustomerId; use windmill_audit::{audit_log, ActionKind}; use windmill_common::{ - error::{Error, JsonResult, Result}, + error::{to_anyhow, Error, JsonResult, Result}, flows::Flow, scripts::{Schema, Script, ScriptLang}, utils::{paginate, rd_string, require_admin, Pagination}, @@ -54,6 +55,7 @@ pub fn workspaced_service() -> Router { .route("/tarball", get(tarball_workspace)) .route("/premium_info", get(premium_info)) .route("/checkout", get(stripe_checkout)) + .route("/billing_portal", get(stripe_portal)) } pub fn global_service() -> Router { Router::new() @@ -86,6 +88,8 @@ pub struct WorkspaceSettings { pub slack_email: String, pub auto_invite_domain: Option, pub auto_invite_operator: Option, + pub customer_id: Option, + pub plan: Option, } #[derive(FromRow, Serialize, Debug)] @@ -205,45 +209,138 @@ async fn premium_info( Ok(Json(row)) } -async fn stripe_checkout(authed: Authed, Extension(base_url): Extension>) { - #[cfg(feature = "enterprise")] +#[derive(Deserialize)] +struct PlanQuery { + plan: String, +} + +async fn stripe_checkout( + authed: Authed, + Path(w_id): Path, + Query(plan): Query, + Extension(base_url): 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 success_rd = format!( - "{}/workspace_settings?session={{CHECKOUT_SESSION_ID}}", - base_url.0 - ); - let failure_rd = format!("{}/workspace_settings", base_url.0); + let success_rd = format!("{}/workspace_settings/checkout?success=true", base_url.0); + let failure_rd = format!("{}/workspace_settings/checkout?success=false", base_url.0); let checkout_session = { let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd); params.mode = Some(stripe::CheckoutSessionMode::Subscription); - params.line_items = Some(vec![ - stripe::CreateCheckoutSessionLineItems { - quantity: None, - price: Some("price_1MQzMHGU3NdFi9eLWFC7IXEv".to_string()), - ..Default::default() - }, - stripe::CreateCheckoutSessionLineItems { - quantity: None, - price: Some("price_1MR2BZGU3NdFi9eLNRuibxPx".to_string()), - ..Default::default() - }, - ]); + params.line_items = match plan.plan.as_str() { + "team" => Some(vec![ + stripe::CreateCheckoutSessionLineItems { + quantity: None, + price: Some("price_1MSdSyGU3NdFi9eLMdV6cS6F".to_string()), + ..Default::default() + }, + stripe::CreateCheckoutSessionLineItems { + quantity: None, + price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()), + ..Default::default() + }, + ]), + "enterprise" => Some(vec![ + stripe::CreateCheckoutSessionLineItems { + quantity: None, + price: Some("price_1MSdf6GU3NdFi9eLJFRkntlx".to_string()), + ..Default::default() + }, + stripe::CreateCheckoutSessionLineItems { + quantity: None, + price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()), + ..Default::default() + }, + ]), + _ => Err(Error::BadRequest("invalid plan".to_string()))?, + }; params.customer_email = Some(&authed.email); - params.client_reference_id = Some("foo"); + params.client_reference_id = Some(&w_id); stripe::CheckoutSession::create(&client, params) .await .unwrap() }; - - println!( - "created a {} at {}", - checkout_session.payment_status, - checkout_session.url.unwrap() - ); + let uri = checkout_session + .url + .ok_or_else(|| Error::InternalErr(format!("stripe checkout redirect issue")))?; + Ok(Redirect::to(&uri)) } } +async fn stripe_portal( + authed: Authed, + Path(w_id): Path, + Extension(db): Extension, + Extension(base_url): Extension>, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let customer_id = sqlx::query_scalar!( + "SELECT customer_id FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .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 success_rd = format!("{}/workspace_settings?tab=premium", base_url.0); + let portal_session = { + let customer_id = CustomerId::from_str(&customer_id).unwrap(); + let mut params = stripe::CreateBillingPortalSession::new(customer_id); + params.return_url = Some(&success_rd); + stripe::BillingPortalSession::create(&client, params) + .await + .map_err(to_anyhow)? + }; + Ok(Redirect::to(&portal_session.url)) +} + +// async fn stripe_usage( +// authed: Authed, +// Path(w_id): Path, +// Extension(db): Extension, +// Extension(base_url): Extension>, +// ) -> Result { +// require_admin(authed.is_admin, &authed.username)?; +// let customer_id = sqlx::query_scalar!( +// "SELECT customer_id FROM workspace_settings WHERE workspace_id = $1", +// w_id +// ) +// .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 success_rd = format!("{}/workspace_settings?tab=premium", base_url.0); +// let portal_session = { +// let customer_id = CustomerId::from_str(&customer_id).unwrap(); +// let subscriptions = stripe::Subscription::list( +// &client, +// stripe::ListSubscriptions { customer: Some(customer_id), ..Default::default() }, +// ) +// .await +// .map_err(to_anyhow)? +// .data[0]; +// let getUsage = +// stripe::SubscriptionItem::list( +// &client, +// stripe::ListSubscriptionItems { +// subscription: subscription.id, +// ..Default::default() +// }, +// ) +// .await +// .map_err(to_anyhow) +// }; +// let mut params = stripe::ListSubscriptionItems::new(customer_id); +// params.return_url = Some(&success_rd); +// stripe::BillingPortalSession::create(&client, params) +// .await +// .map_err(to_anyhow)? +// }; +// } + async fn exists_workspace( authed: Authed, Extension(user_db): Extension, diff --git a/frontend/src/lib/components/ModulePreview.svelte b/frontend/src/lib/components/ModulePreview.svelte index a18e657ed8..17b4f37334 100644 --- a/frontend/src/lib/components/ModulePreview.svelte +++ b/frontend/src/lib/components/ModulePreview.svelte @@ -1,6 +1,6 @@ @@ -58,7 +61,7 @@
    {#each keys as key, index}
  • - + {/if} + + {:else} +
    + +
    + {/if} {/if} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 97ef47729e..25ed1089f5 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -2,7 +2,8 @@ import { browser } from "$app/environment"; import { derived, type Readable, writable } from "svelte/store"; import type { UserWorkspaceList } from "$lib/gen/models/UserWorkspaceList.js"; import { getUserExt } from "./user"; -import type { TokenResponse } from "./gen"; +import { WorkspaceService, type TokenResponse } from "./gen"; +import { isCloudHosted } from "./utils"; export interface UserExt { email: string; @@ -24,6 +25,7 @@ export const userStore = writable(undefined); export const workspaceStore = writable( persistedWorkspace ? String(persistedWorkspace) : undefined, ); +export const premiumStore = writable<{ premium: boolean, usage?: number }>({ premium: false }); export const starStore = writable(1); export const usersWorkspaceStore = writable( undefined, @@ -72,6 +74,9 @@ if (browser) { } userStore.set(await getUserExt(workspace)); + if (isCloudHosted()) { + premiumStore.set((await WorkspaceService.getPremiumInfo({ workspace }))); + } } else { userStore.set(undefined); } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b719f7cabb..42bdf47a9a 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -655,7 +655,7 @@ export function addWhitespaceBeforeCapitals(word?: string): string { } export function isCloudHosted(): boolean { - return get(page).url.hostname == 'app.windmill.dev' + return (get(page)?.url?.hostname == 'app.windmill.dev') } export function isObject(obj: any) { diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 68ab1cc33b..c54b6db681 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -16,14 +16,15 @@ import { goto } from '$app/navigation' import InviteUser from '$lib/components/InviteUser.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' - import { Badge, Button, Skeleton } from '$lib/components/common' + import { Alert, Badge, Button, Skeleton, Tab, Tabs } from '$lib/components/common' import Tooltip from '$lib/components/Tooltip.svelte' - import { faScroll, faBarsStaggered } from '@fortawesome/free-solid-svg-icons' + import { faScroll, faBarsStaggered, faExternalLink } from '@fortawesome/free-solid-svg-icons' import SearchItems from '$lib/components/SearchItems.svelte' import Toggle from '$lib/components/Toggle.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton/ToggleButton.svelte' import AddUser from '$lib/components/AddUser.svelte' + import { page } from '$app/stores' let users: User[] | undefined = undefined let invites: WorkspaceInvite[] = [] @@ -37,6 +38,11 @@ let operatorOnly: boolean | undefined = undefined let premium_info: { premium: boolean; usage?: number } | undefined = undefined let nbDisplayed = 30 + let plan: string | undefined = undefined + let customer_id: string | undefined = undefined + let tab: 'users' | 'slack' | 'premium' | 'export_delete' = + ($page.url.searchParams.get('tab') as 'users' | 'slack' | 'premium' | 'export_delete') ?? + 'users' // function getDropDownItems(username: string): DropdownItem[] { // return [ @@ -77,6 +83,8 @@ auto_invite_domain = settings.auto_invite_domain operatorOnly = settings.auto_invite_operator scriptPath = (settings.slack_command_script ?? '').split('/').slice(1).join('/') + plan = settings.plan + customer_id = settings.customer_id initialPath = scriptPath } @@ -127,6 +135,32 @@ ) ) } + + const plans = { + Free: [ + 'Users use their individual global free-tier quotas when doing executions in this workspace', + '1 000 free global executions per-user per month' + ], + Team: [ + `$10/month per user in the workspace.`, + `Executions are not accounted for in the global user's + quotas but are accounted for in the workspace's quota.`, + `Every user in the workspace increases the pooled workspace quota by 10k executions.`, + `
    10k executions/user
    `, + `$0.001 per additional execution (1$ per 1000 executions)` + ], + Enterprise: [ + `$50/month per user in the workspace.`, + `Executions are not accounted for in the global user's + quotas but are accounted for in the workspace's quota.`, + `Every user in the workspace increases the pooled workspace quota by 50k executions.`, + `Dedicated workers and database`, + `SAML support`, + `Priority support including an automation engineer`, + `
    50k executions/user
    `, + `$0.001 per additional execution (1$ per 1000 executions)` + ] + } - {#if $userStore?.is_admin} + {#if $userStore?.is_admin || $superadmin} - + + +
    Users & Invites
    +
    + +
    Slack Command
    +
    + {#if isCloudHosted()} + +
    Premium Plans
    +
    + {/if} + +
    Export & Delete Workspace
    +
    +
    + {#if tab == 'users'} + - + -
    - -
    -
    - - - email - username - executions (5w) An execution is calculated as 1 for any runs of scripts + 1 for each seconds above - the first one - - - - - - - {#if filteredUsers} - {#each filteredUsers.slice(0, nbDisplayed) as { email, username, is_admin, operator, usage, disabled } (email)} +
    + +
    +
    + + + email + username + executions (5w) An execution is calculated as 1 for any runs of scripts + 1 for each seconds above + the first one + + + + + + + {#if filteredUsers} + {#each filteredUsers.slice(0, nbDisplayed) as { email, username, is_admin, operator, usage, disabled } (email)} + + {email} + {username} + {usage?.executions} +
    {#if disabled} + disabled + {/if}
    + +
    + { + if (is_admin && e.detail != 'admin') { + sendUserToast( + 'Admins cannot be demoted by themselves, ask another admin to demote you', + true + ) + e.preventDefault() + listUsers() + return + } + const body = + e.detail == 'admin' + ? { is_admin: true, operator: false } + : e.detail == 'operator' + ? { is_admin: false, operator: true } + : { is_admin: false, operator: false } + await UserService.updateUser({ + workspace: $workspaceStore ?? '', + username, + requestBody: body + }) + listUsers() + }} + > + Operator An operator can only execute and view scripts/flows/apps from your + workspace, and only those that he has visibility on + Author An Author can execute and view scripts/flows/apps, but he can also + create new ones + Admin + +
    + + +
    + + | + +
    + + + {/each} + {#if filteredUsers?.length > 50} + {nbDisplayed} items out of {filteredUsers.length} + + {/if} + {:else} + {#each new Array(6) as _} + + {#each new Array(4) as _} + + + + {/each} + + {/each} + {/if} + +
    +
    + + + + +
    + + + email + role + + + + {#each invites as { email, is_admin, operator }} {email} - {username} - {usage?.executions}
    {#if disabled} - disabled - {/if}
    {#if operator}operator{:else if is_admin}admin{/if} + + + - -
    - { - const body = - e.detail == 'admin' - ? { is_admin: true, operator: false } - : e.detail == 'operator' - ? { is_admin: false, operator: true } - : { is_admin: false, operator: false } - await UserService.updateUser({ - workspace: $workspaceStore ?? '', - username, - requestBody: body - }) - listUsers() - }} - > - Operator An operator can only execute and view scripts/flows/apps from your - workspace, and only those that he has visibility on - Author An Author can execute and view scripts/flows/apps, but he can also create - new ones - Admin - -
    - - -
    - - | - -
    - {/each} - {#if filteredUsers?.length > 50} - {nbDisplayed} items out of {filteredUsers.length} - - {/if} - {:else} - {#each new Array(6) as _} - - {#each new Array(4) as _} - - - - {/each} - - {/each} - {/if} - -
    -
    - - - + +
    +
    -
    - - - email - role - - - - {#each invites as { email, is_admin, operator }} - - {email} - {#if operator}operator{:else if is_admin}admin{/if} - - - - - {/each} - - -
    - - {#if isCloudHosted()}
    - - {#if premium_info?.premium}This workspace is on a team plan. The number of executions is - tracked globally. Current number of executions in this workspace since it was switched to - the team Plan for this month: {premium_info.usage ?? 0} - {:else} - This workspace is NOT on a team plan. Users use their global free-tier quotas when - doing executions in this workspace. Upgrade to a Team plan to unlock unlimited execution in - this workspace. -
    - -
    - {/if} - {/if} - -
    - -
    - {#if auto_invite_domain != domain} -
    - -
    - {/if} - {#if auto_invite_domain} -
    - { - await removeAllInvitesFromDomain() - await WorkspaceService.editAutoInvite({ - workspace: $workspaceStore ?? '', - requestBody: { operator: e.detail } - }) - loadSettings() - listInvites() - }} - /> + +
    + {#if auto_invite_domain != domain}
    Set auto-invite to {domain}
    + {/if} + {#if auto_invite_domain} +
    + { + await removeAllInvitesFromDomain() + await WorkspaceService.editAutoInvite({ + workspace: $workspaceStore ?? '', + requestBody: { operator: e.detail } + }) + loadSettings() + listInvites() + }} + /> +
    + +
    +
    + {/if} +
    + {#if !allowedAutoDomain} +
    {domain} domain not allowed for auto-invite
    + {/if} + {:else if tab == 'premium'} + {#if isCloudHosted()} +
    + {#if customer_id} +
    + +

    + See invoices, change billing information or subscription details

    +
    + {/if} + +
    + {#if premium_info?.premium} +
    +
    Current plan:
    {plan ?? 'Free plan'}
    + {#if plan} + {@const team_factor = plan == 'team' ? 10 : 50} + {@const max = (users?.length ?? 0) * team_factor} + +
    + Current number of seats in this workspace: +
    {users?.length ?? 0} * ${team_factor} = ${(users?.length ?? 0) * + team_factor}/mo
    + Actual pricing is calculated on the MAXIMUM number of users in a given billing + period, see the customer portal for more info. +
    + +
    + Included number of executions based on your seats and plan: +
    {users?.length ?? 0} seats x {plan == 'team' ? '10k' : '50k'} = {max}k +
    +
    + {/if} +
    + {:else} + This workspace is NOT on a team plan. Users use their global free-tier quotas when + doing executions in this workspace. Upgrade to a Team or Enterprise plan to unlock unlimited + execution in this workspace. + {/if} +
    + +
    + + The single credit-unit is called an "execution". An execution corresponds to a single + job whose duration is less than 1s. For any additional seconds of execution, an + additional execution is accounted for. Jobs are executed on powerful cpus. Most jobs + will take less than 200ms to execute. + +
    + +
    + {#each Object.entries(plans) as [planTitle, planDesc]} +
    +

    {planTitle}

    +
      + {#each planDesc as item} +
    • {@html item}
    • + {/each} +
    + +
    + {#if planTitle == 'Team'} + {#if plan != 'team'} +
    + +
    + {:else} +
    Workspace is on the team plan
    + {/if} + {:else if planTitle == 'Enterprise'} + {#if plan != 'enterprise'} +
    + +
    + {:else} +
    Workspace is on enterprise plan
    + {/if} + {:else if !plan} +
    Workspace is on the free plan
    + {:else} +
    + +
    + {/if} +
    + {/each}
    {/if} -
    - {#if !allowedAutoDomain} -
    {domain} domain not allowed for auto-invite
    - {/if} -
    - -

    - Status: {#if team_name}Connected to slack workspace {team_name}{:else}Not - connected{/if} -

    - - {#if team_name} -
    - + + +
    + {:else} + - -
    - {:else} - - {/if} -

    Script or flow to run on /windmill command - The script or flow to be triggered when the `/windmill` command is invoked. The script or - flow chosen is passed the parameters
    response_url: string, text: string
    - respectively the url to reply directly to the trigger and the text of the command.
    -

    - -
    - -
    - -
    - -
    - -

    - The workspace will be archived for a short period of time and then permanently deleted -

    - {#if $workspaceStore === 'admins' || $workspaceStore === 'starter'} +
    +

    - This workspace cannot be deleted as it has a special function. Consult the documentation for - more information. + The workspace will be archived for a short period of time and then permanently deleted

    - {/if} -
    - - - {#if $superadmin} + {#if $workspaceStore === 'admins' || $workspaceStore === 'starter'} +

    + This workspace cannot be deleted as it has a special function. Consult the documentation + for more information. +

    + {/if} +
    - {/if} -
    + + {#if $superadmin} + + {/if} +
    + {/if} {:else}