diff --git a/backend/.sqlx/query-f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583.json b/backend/.sqlx/query-f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583.json
new file mode 100644
index 0000000000..cce644b300
--- /dev/null
+++ b/backend/.sqlx/query-f47cafb6e9a6ec58ccafb06cf5e806e3fe749119214863b9111b58fff0bb9583.json
@@ -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"
+}
diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt
index 5e9eb24ee5..a1d9c874fc 100644
--- a/backend/ee-repo-ref.txt
+++ b/backend/ee-repo-ref.txt
@@ -1 +1 @@
-2d6c66b32f20d9605c6a677727473ab66fcc8a87
+9ff97cd818e85940fec282c92161e98c1b8583e2
diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs
index 437a580612..c8c49416d8 100644
--- a/backend/windmill-api-workspaces/src/workspaces.rs
+++ b/backend/windmill-api-workspaces/src/workspaces.rs
@@ -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,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ operators: Option,
+ seats: i64,
+}
+
+async fn get_billable_seats(
+ _authed: ApiAuthed,
+ Extension(db): Extension,
+ Path(w_id): Path,
+) -> JsonResult {
+ // 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,
@@ -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, 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) {
+ 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.
diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml
index b10f0424e3..1a14a9fcf6 100644
--- a/backend/windmill-api/openapi.yaml
+++ b/backend/windmill-api/openapi.yaml
@@ -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
diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs
index ae3e6546cd..c1d8fc00bc 100644
--- a/backend/windmill-common/src/workspaces.rs
+++ b/backend/windmill-common/src/workspaces.rs
@@ -759,15 +759,24 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result {
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 {
+pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result {
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 {
)
.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 {
+ Ok(billable_seats(db, w_id).await?.seats)
}
#[cfg(feature = "cloud")]
diff --git a/backend/windmill-common/tests/billing_workspace.rs b/backend/windmill-common/tests/billing_workspace.rs
index 1b0186a5cf..3025061249 100644
--- a/backend/windmill-common/tests/billing_workspace.rs
+++ b/backend/windmill-common/tests/billing_workspace.rs
@@ -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) {
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;
diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte
index a1036a0ff6..42bfef9d65 100644
--- a/frontend/src/lib/components/settings/PremiumInfo.svelte
+++ b/frontend/src/lib/components/settings/PremiumInfo.svelte
@@ -1,11 +1,11 @@
@@ -154,17 +145,15 @@
{#snippet actions()}
-
-
-
+
{/snippet}
@@ -260,8 +249,8 @@
Developers
- 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.
@@ -276,8 +265,8 @@
Operators
- 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.
- 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.
@@ -365,8 +354,8 @@
Used seats (billed)
- 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.
u + c = {formatNumber(premiumInfo.usedSeats)}
@@ -398,8 +387,8 @@
Estimate your monthly cost
- 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.
- 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.
Every seat includes 10 000 executions
- 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.
{:else}
@@ -586,9 +573,7 @@
{/if}
{:else}
-
- Workspace is on the team plan
-
+
Workspace is on the team plan
{/if}
{:else if planTitle == 'Enterprise'}
{#if plan != 'enterprise'}
@@ -601,9 +586,7 @@
See more
{:else}
-
- Workspace is on enterprise plan
-
+
Workspace is on enterprise plan
{/if}
{:else if planTitle === 'Free'}
{#if plan}
@@ -611,9 +594,7 @@
Cancel your plan in the Customer Portal to downgrade to the free plan
{:else}
-
- Workspace is on the free plan
-
+
Workspace is on the free plan
{/if}
{/if}
diff --git a/frontend/src/lib/components/sidebar/SidebarUsage.svelte b/frontend/src/lib/components/sidebar/SidebarUsage.svelte
index e62cd02565..1625ed83e2 100644
--- a/frontend/src/lib/components/sidebar/SidebarUsage.svelte
+++ b/frontend/src/lib/components/sidebar/SidebarUsage.svelte
@@ -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()
- const seats = $derived(scopedSeats(billingRootId, seatsResource.current))
+ const seats = $derived(scopedSeats(meteredWorkspace, seatsResource.current))
type QuotaKey = 'user' | 'workspace'
diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
index 782da1bb2c..8dd2937273 100644
--- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte
@@ -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 @@
This workspace is a fork of {currentWorkspace.parent_workspace_id}. 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.
+ {#if plan}
+
+
+ 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
+ {currentWorkspace.parent_workspace_id}'s plan. This workspace keeps
+ running either way, on the parent's plan.
+ {#if customer_id}
+