mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
fix: unify billable seat counting and prevent fork subscriptions (#10818)
* fix: unify billable seat counting and prevent fork subscriptions * fix: authorize candidate before reading its plan, scope seat breakdown * chore: pin ee ref for the stripe checkout fork guard * fix: grant the billable_member view and widen the paid-plan check * refactor: keep the seat rule in rust instead of a view and function * docs: correct the attach guard summary after widening the plan check * revert: keep cloud out of the ci test feature set * chore: update ee-repo-ref to 9ff97cd818e85940fec282c92161e98c1b8583e2 This commit updates the EE repository reference after PR #742 was merged in windmill-ee-private. Previous ee-repo-ref: 0ec0b42565a41f271a45bf24a93467d110c36df3 New ee-repo-ref: 9ff97cd818e85940fec282c92161e98c1b8583e2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
co-authored by
windmill-internal-app[bot]
Ruben Fiszel
parent
3ce9bbc716
commit
7dd88c470c
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT plan FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "plan",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
2d6c66b32f20d9605c6a677727473ab66fcc8a87
|
||||
9ff97cd818e85940fec282c92161e98c1b8583e2
|
||||
|
||||
@@ -117,6 +117,7 @@ pub fn workspaced_service() -> Router {
|
||||
get(get_secondary_storage_names),
|
||||
)
|
||||
.route("/is_premium", get(is_premium))
|
||||
.route("/billable_seats", get(get_billable_seats))
|
||||
.route("/edit_error_handler", post(edit_error_handler))
|
||||
.route("/edit_success_handler", post(edit_success_handler))
|
||||
.route(
|
||||
@@ -686,6 +687,48 @@ async fn is_premium(
|
||||
Ok(Json(premium))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BillableSeatsResponse {
|
||||
/// Both omitted when the seats counted are another workspace's: a fork member need not be a
|
||||
/// member of the billing root, so the root's headcount is not theirs to read. The total is,
|
||||
/// since it is the divisor of the quota their own executions draw on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
developers: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
operators: Option<i64>,
|
||||
seats: i64,
|
||||
}
|
||||
|
||||
async fn get_billable_seats(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<BillableSeatsResponse> {
|
||||
// Readable by any workspace member, like `is_premium`: this is what the sidebar usage meter
|
||||
// divides by, and that meter is shown to non-admin developers too.
|
||||
//
|
||||
// On cloud a fork draws its plan, quota and bill from the root, so the seats its usage is
|
||||
// measured against are the root's. Resolved here rather than by the caller: a fork member need
|
||||
// not be a member of that root, and so cannot count its seats from the member list. Off cloud
|
||||
// a fork is not billed through a root at all, so the workspace answers for itself.
|
||||
#[cfg(feature = "cloud")]
|
||||
let billing_w_id = if *CLOUD_HOSTED {
|
||||
windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await?
|
||||
} else {
|
||||
w_id.clone()
|
||||
};
|
||||
#[cfg(not(feature = "cloud"))]
|
||||
let billing_w_id = w_id.clone();
|
||||
|
||||
let counted = windmill_common::workspaces::billable_seats(&db, &billing_w_id).await?;
|
||||
let own = billing_w_id == w_id;
|
||||
Ok(Json(BillableSeatsResponse {
|
||||
developers: own.then_some(counted.developers),
|
||||
operators: own.then_some(counted.operators),
|
||||
seats: counted.seats,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn exists_workspace(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -7374,6 +7417,76 @@ async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<()
|
||||
enforce_cloud_fork_count(db, &root, 1).await
|
||||
}
|
||||
|
||||
/// Cloud: refuse to attach a workspace that already has a paid plan of its own.
|
||||
///
|
||||
/// Once attached it draws the root's plan and meters its usage there, so a subscription of its own
|
||||
/// bills a second time for one plan. Only an attach can reach this state: a fork is created as a
|
||||
/// fresh workspace and never had a plan to keep.
|
||||
///
|
||||
/// Asked only of a candidate joining this family, never of one already under the same root: that
|
||||
/// one is already in the double-billed state, where the settings page surfaces the leftover
|
||||
/// subscription and the portal that cancels it, and refusing there would block re-designating a
|
||||
/// renamed dev workspace over a billing problem the attach did not cause.
|
||||
#[cfg(feature = "cloud")]
|
||||
async fn reject_attach_of_subscribed_workspace(db: &DB, dev_w_id: &str) -> Result<()> {
|
||||
let plan = sqlx::query_scalar!(
|
||||
"SELECT plan FROM workspace_settings WHERE workspace_id = $1",
|
||||
dev_w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
// Any plan, not just `'team'`: the column is written by the subscription webhook, and a plan
|
||||
// value it does not write yet would otherwise walk straight past this. An enterprise
|
||||
// arrangement is deliberately not covered — it sets `premium` without a plan and has no
|
||||
// self-serve portal, so refusing there would be a dead end rather than something to act on.
|
||||
if plan.is_some() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Workspace {dev_w_id} is on a paid plan of its own. A dev or fork workspace runs on its parent's plan and is never invoiced separately, so cancel that subscription from its own billing settings before attaching it."
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "cloud"))]
|
||||
mod attach_billing_guard_tests {
|
||||
use super::reject_attach_of_subscribed_workspace;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
async fn workspace_on_plan(db: &Pool<Postgres>, id: &str, plan: Option<&str>) {
|
||||
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ($1, $1, 'test-user')")
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.expect("insert workspace");
|
||||
sqlx::query("INSERT INTO workspace_settings (workspace_id, plan) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(plan)
|
||||
.execute(db)
|
||||
.await
|
||||
.expect("insert workspace_settings");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations")]
|
||||
async fn refuses_a_candidate_that_still_pays_for_itself(db: Pool<Postgres>) {
|
||||
workspace_on_plan(&db, "subscribed", Some("team")).await;
|
||||
workspace_on_plan(&db, "cancelled", None).await;
|
||||
|
||||
let err = reject_attach_of_subscribed_workspace(&db, "subscribed")
|
||||
.await
|
||||
.expect_err("a workspace on a paid plan of its own must not be attachable");
|
||||
assert!(err.to_string().contains("paid plan of its own"), "{err}");
|
||||
|
||||
// Cancelling clears `plan` but keeps `customer_id`, so the plan column is what decides.
|
||||
reject_attach_of_subscribed_workspace(&db, "cancelled")
|
||||
.await
|
||||
.expect("a workspace with no plan is attachable");
|
||||
reject_attach_of_subscribed_workspace(&db, "no-settings-row")
|
||||
.await
|
||||
.expect("a workspace with no settings row is attachable");
|
||||
}
|
||||
}
|
||||
|
||||
/// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper
|
||||
/// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new
|
||||
/// node — 0 for a plain fork, or the candidate's own subtree height for an attach.
|
||||
@@ -7916,6 +8029,17 @@ async fn attach_dev_workspace(
|
||||
)));
|
||||
}
|
||||
|
||||
// Deliberately below the admin-of-candidate check, unlike the cap enforcement above: the
|
||||
// refusal names the candidate's plan, so running it earlier would tell any admin of any
|
||||
// premium workspace whether an arbitrary workspace id is on a team plan.
|
||||
#[cfg(feature = "cloud")]
|
||||
if *CLOUD_HOSTED {
|
||||
let root = windmill_common::workspaces::get_billing_workspace_id(&db, &prod_w_id).await?;
|
||||
if windmill_common::workspaces::get_billing_workspace_id(&db, &dev_w_id).await? != root {
|
||||
reject_attach_of_subscribed_workspace(&db, &dev_w_id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
// Everything above ran outside a transaction, so prod's eligibility and the chain's labels could
|
||||
// have changed under us: re-decide both here, under the pairing lock.
|
||||
|
||||
@@ -3788,6 +3788,38 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/workspaces/billable_seats:
|
||||
get:
|
||||
summary: get the billable seats of the workspace the plan is billed on
|
||||
operationId: getBillableSeats
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: billable seats
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
developers:
|
||||
type: integer
|
||||
description: >-
|
||||
Omitted when the seats counted are another workspace's, as they are for a
|
||||
fork resolving to its billing root.
|
||||
operators:
|
||||
type: integer
|
||||
description: >-
|
||||
Omitted when the seats counted are another workspace's, as they are for a
|
||||
fork resolving to its billing root.
|
||||
seats:
|
||||
type: integer
|
||||
required:
|
||||
- seats
|
||||
|
||||
/w/{workspace}/workspaces/premium_info:
|
||||
get:
|
||||
summary: get premium info
|
||||
|
||||
@@ -759,15 +759,24 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result<i64> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Approximate paid seats of a workspace as `ceil(developers + operators/2)`, excluding disabled and
|
||||
/// service-account members. Reuses billing's author/operator weighting, but counts provisioned
|
||||
/// members rather than the active-user population billing meters, so it only ever loosens the fork
|
||||
/// cap (never blocks a paid seat) — good enough for a soft guardrail.
|
||||
/// The billable members of a workspace and the seats they add up to.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct BillableSeats {
|
||||
pub developers: i64,
|
||||
pub operators: i64,
|
||||
pub seats: i64,
|
||||
}
|
||||
|
||||
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
|
||||
/// accounts cannot log in and do not take a seat; a disabled member is not billed either.
|
||||
///
|
||||
/// The workspace is invoiced by a job outside this codebase that counts the same rows with its own
|
||||
/// SQL. The two must be changed together: this rule disagreeing with that one is what bills a
|
||||
/// workspace for seats the product never credits it for.
|
||||
///
|
||||
/// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be
|
||||
/// authorized for that workspace (or run in trusted server-side code).
|
||||
#[cfg(feature = "cloud")]
|
||||
pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
|
||||
pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result<BillableSeats> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT
|
||||
COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!",
|
||||
@@ -777,8 +786,18 @@ pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("counting paid seats of {w_id}: {e:#}")))?;
|
||||
Ok(((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64)
|
||||
.map_err(|e| Error::internal_err(format!("counting billable seats of {w_id}: {e:#}")))?;
|
||||
Ok(BillableSeats {
|
||||
developers: row.developers,
|
||||
operators: row.operators,
|
||||
seats: ((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Seats only, for the fork cap. See [`billable_seats`].
|
||||
#[cfg(feature = "cloud")]
|
||||
pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
|
||||
Ok(billable_seats(db, w_id).await?.seats)
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::workspaces::{
|
||||
count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height,
|
||||
billable_seats, count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height,
|
||||
get_billing_workspace_id, invalidate_billing_workspace_cache, list_fork_descendants,
|
||||
};
|
||||
|
||||
@@ -106,11 +106,17 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
|
||||
insert_member(&db, "seat-root", "dev2@w.dev", false, false, false).await;
|
||||
insert_member(&db, "seat-root", "op1@w.dev", true, false, false).await;
|
||||
insert_member(&db, "seat-root", "op2@w.dev", true, false, false).await;
|
||||
// These must NOT count towards seats.
|
||||
// These must NOT count towards seats. The service account is a non-operator, so counting it
|
||||
// would inflate the developer tally the invoice line is written from, not the operator one.
|
||||
insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await;
|
||||
insert_member(&db, "seat-root", "svc@w.dev", false, false, true).await;
|
||||
|
||||
assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 3);
|
||||
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
|
||||
assert_eq!(
|
||||
(breakdown.developers, breakdown.operators, breakdown.seats),
|
||||
(2, 2, 3)
|
||||
);
|
||||
|
||||
insert_ws(&db, "seat-fork1", Some("seat-root"), false).await;
|
||||
insert_ws(&db, "seat-fork2", Some("seat-root"), false).await;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
import { run } from 'svelte/legacy'
|
||||
|
||||
import { base } from '$lib/base'
|
||||
import { capitalize, pluralize, sendUserToast } from '$lib/utils'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import { WorkspaceService, type User, UserService } from '$lib/gen'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Button } from '../common'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
@@ -17,13 +17,11 @@
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
interface Props {
|
||||
plan: string | undefined;
|
||||
customer_id: string | undefined;
|
||||
plan: string | undefined
|
||||
customer_id: string | undefined
|
||||
}
|
||||
|
||||
let { plan, customer_id }: Props = $props();
|
||||
|
||||
let users: User[] | undefined = undefined
|
||||
let { plan, customer_id }: Props = $props()
|
||||
|
||||
let premiumInfo:
|
||||
| {
|
||||
@@ -61,33 +59,29 @@
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async function listUsers(): Promise<void> {
|
||||
users = await UserService.listUsers({ workspace: $workspaceStore! })
|
||||
}
|
||||
|
||||
async function loadPremiumInfo() {
|
||||
const info = await WorkspaceService.getPremiumInfo({ workspace: $workspaceStore! })
|
||||
// Same basis as the backend's `count_paid_seats`, which excludes disabled members
|
||||
// and service accounts: counting them here would put this page at a different
|
||||
// seat count from the rest of the product.
|
||||
const billable = users?.filter((x) => !x.disabled && !x.is_service_account) ?? []
|
||||
const developerNb = billable.filter((x) => !x.operator).length
|
||||
const operatorNb = billable.length - developerNb
|
||||
// The seat rows come from the server rather than being recounted from the member
|
||||
// list: this page states what the workspace is billed for, so it has to read the
|
||||
// same count the invoice does.
|
||||
const [info, billable] = await Promise.all([
|
||||
WorkspaceService.getPremiumInfo({ workspace: $workspaceStore! }),
|
||||
WorkspaceService.getBillableSeats({ workspace: $workspaceStore! })
|
||||
])
|
||||
const usage = info.usage ?? 0
|
||||
|
||||
const seatsFromUsers = Math.ceil(developerNb + operatorNb / 2)
|
||||
const seatsFromUsers = billable.seats
|
||||
const seatsFromExtraComps = Math.max(Math.ceil(usage / 10000) - seatsFromUsers, 0)
|
||||
const usedSeats = seatsFromUsers + seatsFromExtraComps
|
||||
premiumInfo = {
|
||||
...info,
|
||||
usage,
|
||||
owner: info.owner,
|
||||
developerNb,
|
||||
operatorNb,
|
||||
// Always present here: this page renders only for a workspace billed on its own plan,
|
||||
// which is exactly when the endpoint returns the breakdown.
|
||||
developerNb: billable.developers ?? 0,
|
||||
operatorNb: billable.operators ?? 0,
|
||||
seatsFromUsers,
|
||||
seatsFromExtraComps,
|
||||
usedSeats
|
||||
usedSeats: seatsFromUsers + seatsFromExtraComps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +114,6 @@
|
||||
let estimatedDevsRaw = $state(1)
|
||||
let estimatedOps = $state(0)
|
||||
|
||||
|
||||
let estimatedExecs = $state(1)
|
||||
|
||||
function updateExecs() {
|
||||
@@ -132,17 +125,15 @@
|
||||
const formatNumber = (value: number) => value.toLocaleString('en-US')
|
||||
run(() => {
|
||||
if ($workspaceStore) {
|
||||
// The seat rows are computed from the member list and nothing recomputes them
|
||||
// when it lands, so a fast `premium_info` would render zero seats and keep them.
|
||||
listUsers().catch(console.warn).then(loadPremiumInfo)
|
||||
loadPremiumInfo()
|
||||
getThresholdAlert()
|
||||
}
|
||||
});
|
||||
})
|
||||
let estimatedDevs = $derived(Math.max(1, estimatedDevsRaw))
|
||||
let estimatedSeats = $derived(estimatedDevs + Math.ceil(estimatedOps / 2))
|
||||
run(() => {
|
||||
estimatedSeats && updateExecs()
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<Modal bind:open={thresholdAlertOpen} title="Threshold alert">
|
||||
@@ -154,17 +145,15 @@
|
||||
</div>
|
||||
|
||||
{#snippet actions()}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => {
|
||||
setThresholdAlert()
|
||||
thresholdAlertOpen = false
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => {
|
||||
setThresholdAlert()
|
||||
thresholdAlertOpen = false
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -260,8 +249,8 @@
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="font-medium">Developers</div>
|
||||
<p class="text-xs text-secondary">
|
||||
Calculated on the MAXIMUM number of users in a given billing
|
||||
period, see the Customer Portal for more info.
|
||||
Calculated on the MAXIMUM number of users in a given billing period, see the
|
||||
Customer Portal for more info.
|
||||
</p>
|
||||
</div>
|
||||
</Cell>
|
||||
@@ -276,8 +265,8 @@
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="font-medium">Operators</div>
|
||||
<p class="text-xs text-secondary">
|
||||
Calculated on the MAXIMUM number of operators in a given
|
||||
billing period, see the Customer Portal for more info.
|
||||
Calculated on the MAXIMUM number of operators in a given billing period, see
|
||||
the Customer Portal for more info.
|
||||
</p>
|
||||
</div>
|
||||
</Cell>
|
||||
@@ -295,8 +284,9 @@
|
||||
1 developer = 1 seat, 2 operators = 1 seat.
|
||||
</p>
|
||||
<p class="text-[11px] text-secondary font-mono">
|
||||
u = ceil({formatNumber(premiumInfo.developerNb)} + {formatNumber(premiumInfo.operatorNb)}/2)
|
||||
= {formatNumber(premiumInfo.seatsFromUsers)}
|
||||
u = ceil({formatNumber(premiumInfo.developerNb)} + {formatNumber(
|
||||
premiumInfo.operatorNb
|
||||
)}/2) = {formatNumber(premiumInfo.seatsFromUsers)}
|
||||
</p>
|
||||
</div>
|
||||
</Cell>
|
||||
@@ -311,9 +301,8 @@
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="font-semibold">Executions this month</div>
|
||||
<p class="text-xs text-secondary">
|
||||
One execution equals one job
|
||||
up to 1 second on a worker with 2GB of memory, with each additional
|
||||
second counting as an extra execution.
|
||||
One execution equals one job up to 1 second on a worker with 2GB of memory,
|
||||
with each additional second counting as an extra execution.
|
||||
</p>
|
||||
</div>
|
||||
</Cell>
|
||||
@@ -365,8 +354,8 @@
|
||||
Used seats (billed)
|
||||
</div>
|
||||
<p class="text-xs text-secondary">
|
||||
Highest between seats from 'Developers + Operators' and 'Seats from executions'.
|
||||
This is the number of seats used for billing this month.
|
||||
Highest between seats from 'Developers + Operators' and 'Seats from
|
||||
executions'. This is the number of seats used for billing this month.
|
||||
</p>
|
||||
<p class="text-[11px] text-secondary font-mono">
|
||||
u + c = {formatNumber(premiumInfo.usedSeats)}
|
||||
@@ -398,8 +387,8 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-semibold text-primary">Estimate your monthly cost</div>
|
||||
<p class="text-xs text-secondary max-w-xl">
|
||||
This is a rough estimate based on your expected team size and workload. Actual billing is based
|
||||
on the maximum number of users and executions in a given month.
|
||||
This is a rough estimate based on your expected team size and workload. Actual billing is
|
||||
based on the maximum number of users and executions in a given month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -420,13 +409,13 @@
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-sm font-medium text-primary">Operators</div>
|
||||
<div class="text-xs text-secondary">
|
||||
<span class="font-semibold">{estimatedOps}</span> operator{estimatedOps === 1 ? '' : 's'}
|
||||
<span class="font-semibold">{estimatedOps}</span> operator{estimatedOps === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</div>
|
||||
</div>
|
||||
<Range min={0} max={20} bind:value={estimatedOps} hideInput />
|
||||
<p class="text-[11px] text-secondary">
|
||||
2 operators = 1 seat
|
||||
</p>
|
||||
<p class="text-[11px] text-secondary"> 2 operators = 1 seat </p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
@@ -434,8 +423,8 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="text-sm font-medium text-primary">Monthly executions</div>
|
||||
<Tooltip>
|
||||
One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory, with
|
||||
each additional second counting as an extra execution.
|
||||
One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory,
|
||||
with each additional second counting as an extra execution.
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="text-xs text-secondary">
|
||||
@@ -449,9 +438,7 @@
|
||||
format={(v) => `${v * 10}k`}
|
||||
hideInput
|
||||
/>
|
||||
<p class="text-[11px] text-secondary">
|
||||
Each seat includes 10k executions per month.
|
||||
</p>
|
||||
<p class="text-[11px] text-secondary"> Each seat includes 10k executions per month. </p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -558,8 +545,8 @@
|
||||
<li class="mt-2">
|
||||
Every seat includes <b>10 000</b> executions
|
||||
<Tooltip>
|
||||
One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory, with
|
||||
each additional second counting as an extra execution.
|
||||
One execution equals one job up to 1 second on a virtual CPU with 2 GB of memory,
|
||||
with each additional second counting as an extra execution.
|
||||
</Tooltip>
|
||||
</li>
|
||||
{:else}
|
||||
@@ -586,9 +573,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-md font-semibold">
|
||||
Workspace is on the team plan
|
||||
</div>
|
||||
<div class="text-md font-semibold"> Workspace is on the team plan </div>
|
||||
{/if}
|
||||
{:else if planTitle == 'Enterprise'}
|
||||
{#if plan != 'enterprise'}
|
||||
@@ -601,9 +586,7 @@
|
||||
See more
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="text-md font-semibold">
|
||||
Workspace is on enterprise plan
|
||||
</div>
|
||||
<div class="text-md font-semibold"> Workspace is on enterprise plan </div>
|
||||
{/if}
|
||||
{:else if planTitle === 'Free'}
|
||||
{#if plan}
|
||||
@@ -611,9 +594,7 @@
|
||||
Cancel your plan in the Customer Portal to downgrade to the free plan
|
||||
</div>
|
||||
{:else}
|
||||
<div class="font-semibold">
|
||||
Workspace is on the free plan
|
||||
</div>
|
||||
<div class="font-semibold"> Workspace is on the free plan </div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,21 +2,18 @@
|
||||
import { resource } from 'runed'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { UserService } from '$lib/gen'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import {
|
||||
isPremiumStore,
|
||||
usageStore,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceMembershipVersion,
|
||||
workspaceStore,
|
||||
workspaceUsageStore,
|
||||
type UserWorkspace
|
||||
workspaceUsageStore
|
||||
} from '$lib/stores'
|
||||
import { refreshExecutions } from '$lib/usage.svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import { scopedValue, tagged } from '$lib/utils/scopedValue'
|
||||
import { findWorkspaceAncestors } from '$lib/utils/workspaceHierarchy'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import { Tooltip } from '$lib/components/meltComponents'
|
||||
@@ -30,51 +27,30 @@
|
||||
|
||||
let open = $state(false)
|
||||
|
||||
// A fork's usage and tier resolve to its billing root while its member list is a
|
||||
// subset of the root's, so seats must come from the root or the cap is fork-sized
|
||||
// against root usage. `undefined` when the root isn't visible from here: the cap
|
||||
// is then unknowable, and the caller hides the meter rather than guessing.
|
||||
function billingRoot(workspace: string, all: UserWorkspace[]): string | undefined {
|
||||
const self = all.find((w) => w.id === workspace)
|
||||
if (!self) return undefined
|
||||
if (!self.parent_workspace_id) return workspace
|
||||
const top = findWorkspaceAncestors(workspace, all).at(-1)
|
||||
return top && !top.parent_workspace_id ? top.id : undefined
|
||||
}
|
||||
// Seat count for a paid workspace, the basis of its included executions. The server
|
||||
// resolves a fork to the workspace its plan is billed on and counts the seats there,
|
||||
// because neither is answerable from here: a fork's member list is a subset of that
|
||||
// root's, and a fork member need not be a member of the root at all.
|
||||
const fetchSeats = tagged(
|
||||
async (workspace: string) => (await WorkspaceService.getBillableSeats({ workspace })).seats
|
||||
)
|
||||
|
||||
// Seat count for a paid workspace, the basis of its included executions. Only the
|
||||
// user list is needed: `premium_info` carries the same usage number as
|
||||
// `workspaceUsageStore` but requires admin and only exists when Stripe is
|
||||
// configured, so it would leave regular members with no block at all.
|
||||
const fetchSeats = tagged(async (root: string) => {
|
||||
// Throws for a fork member with no seat in the root, which is the same answer as
|
||||
// an unresolvable root: leave the paid meter hidden.
|
||||
const users = await UserService.listUsers({ workspace: root })
|
||||
// Same basis as the backend's `count_paid_seats`: disabled members and service
|
||||
// accounts are not billed, so counting them inflates the cap and hides a real
|
||||
// overage. 1 developer = 1 seat, 2 operators = 1 seat.
|
||||
const billable = users.filter((u) => !u.disabled && !u.is_service_account)
|
||||
const developers = billable.filter((u) => !u.operator).length
|
||||
const operators = billable.length - developers
|
||||
return Math.ceil(developers + operators / 2)
|
||||
})
|
||||
|
||||
const billingRootId = $derived.by(() => {
|
||||
const workspace = $workspaceStore
|
||||
if (!isCloudHosted() || !$isPremiumStore || !workspace) return undefined
|
||||
return billingRoot(workspace, $userWorkspaces ?? [])
|
||||
})
|
||||
const meteredWorkspace = $derived(
|
||||
isCloudHosted() && $isPremiumStore ? $workspaceStore : undefined
|
||||
)
|
||||
|
||||
// The membership version is in the key so a change re-resolves the cap, but not in
|
||||
// the tag: tagging by it would blank the bar on every change.
|
||||
const seatsResource = resource(
|
||||
() =>
|
||||
billingRootId ? { root: billingRootId, version: $workspaceMembershipVersion } : undefined,
|
||||
async (key) => (key ? await fetchSeats(key.root) : undefined)
|
||||
meteredWorkspace
|
||||
? { workspace: meteredWorkspace, version: $workspaceMembershipVersion }
|
||||
: undefined,
|
||||
async (key) => (key ? await fetchSeats(key.workspace) : undefined)
|
||||
)
|
||||
|
||||
const scopedSeats = scopedValue<number>()
|
||||
const seats = $derived(scopedSeats(billingRootId, seatsResource.current))
|
||||
const seats = $derived(scopedSeats(meteredWorkspace, seatsResource.current))
|
||||
|
||||
type QuotaKey = 'user' | 'workspace'
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils'
|
||||
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
|
||||
import { Slack, Target } from 'lucide-svelte'
|
||||
import { ExternalLink, Slack, Target } from 'lucide-svelte'
|
||||
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
|
||||
|
||||
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
|
||||
@@ -1472,9 +1472,30 @@
|
||||
<Alert type="info" title="Billing is managed on the parent workspace">
|
||||
This workspace is a fork of <b>{currentWorkspace.parent_workspace_id}</b>. It runs
|
||||
on the parent's plan and its executions count toward the parent's usage and bill,
|
||||
so there is no separate subscription here. Manage billing, seats, and quotas from
|
||||
the parent workspace's settings.
|
||||
so it is never invoiced separately. Manage billing, seats, and quotas from the
|
||||
parent workspace's settings.
|
||||
</Alert>
|
||||
{#if plan}
|
||||
<div class="mt-4">
|
||||
<Alert type="warning" title="This workspace has its own subscription">
|
||||
It is on a paid plan that is billed on its own, so this workspace is paid for
|
||||
twice. Cancel that subscription in the customer portal to keep only
|
||||
<b>{currentWorkspace.parent_workspace_id}</b>'s plan. This workspace keeps
|
||||
running either way, on the parent's plan.
|
||||
{#if customer_id}
|
||||
<div class="mt-3 flex">
|
||||
<Button
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
variant="accent"
|
||||
href="{base}/api/w/{$workspaceStore}/workspaces/billing_portal"
|
||||
>
|
||||
Customer portal
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<PremiumInfo {customer_id} {plan} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user