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:
hugocasa
2026-08-28 17:13:02 +02:00
committed by GitHub
co-authored by windmill-internal-app[bot] Ruben Fiszel
parent 3ce9bbc716
commit 7dd88c470c
9 changed files with 310 additions and 129 deletions
@@ -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
View File
@@ -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.
+32
View File
@@ -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
+27 -8
View File
@@ -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}