diff --git a/apps/sim/app/api/organizations/[id]/usage/summary/route.ts b/apps/sim/app/api/organizations/[id]/usage/summary/route.ts
index ba7abb7372a..4d1111e1962 100644
--- a/apps/sim/app/api/organizations/[id]/usage/summary/route.ts
+++ b/apps/sim/app/api/organizations/[id]/usage/summary/route.ts
@@ -26,6 +26,7 @@ export const GET = defineInternalJsonRoute({
errorPolicy: organizationUsageErrorPolicy,
mapInput: ({ params, query }) => ({
organizationId: params.id,
+ workspaceId: query.workspaceId,
preset: query.preset,
startDate: query.startDate ? new Date(query.startDate) : undefined,
endDate: query.endDate ? new Date(query.endDate) : undefined,
diff --git a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx
index 6a5bde4ff99..6a060b66d4b 100644
--- a/apps/sim/ee/organization-usage/components/usage-monitoring.tsx
+++ b/apps/sim/ee/organization-usage/components/usage-monitoring.tsx
@@ -183,6 +183,18 @@ export function UsageMonitoring({
limit: rowLimitFor('source'),
...(workspace ? { workspaceId: workspace } : {}),
})
+ /**
+ * The same headline and trend the Overview draws, narrowed to this workspace.
+ *
+ * A second summary rather than a figure derived from the lists below it: they carry
+ * totals but no time series, and the shape of the period is the question the chart
+ * answers. It is also the only place the drill-down states its window, which is why
+ * its section is labelled with the period rather than with the word "Usage".
+ */
+ const workspaceSummary = useOrganizationUsageSummary(organizationId, window, {
+ enabled: isWorkspaceDetail,
+ ...(workspace ? { workspaceId: workspace } : {}),
+ })
// Already cached by Members and Billing, so the meter costs nothing extra and
// cannot report a different allowance than they do.
const billing = useOrganizationBilling(organizationId)
@@ -321,6 +333,24 @@ export function UsageMonitoring({
: []
}
>
+ {/*
+ Labelled with the period, not "Usage": the picker lives on the list behind
+ this view, so once you are in here the window is carried but invisible — and
+ a total with no stated period is a number people read as all-time. The
+ heading the chart already needs is where that belongs.
+
+ No allowance passed, unlike the Overview: the limit is pooled across the
+ whole organization, and printing it under one workspace's figure would read
+ as that workspace's own cap.
+ */}
+
+
+
{/*
Sources first, because in most workspaces the majority of usage is Chat
rather than workflow runs — and a workflow list alone hid that behind a
@@ -452,6 +482,7 @@ export function UsageMonitoring({
}
isLoading={summary.isLoading}
isError={summary.isError}
+ isPlaceholderData={summary.isPlaceholderData}
/>
{/*
diff --git a/apps/sim/ee/organization-usage/components/usage-summary.tsx b/apps/sim/ee/organization-usage/components/usage-summary.tsx
index 0acdeec924a..41e5d5643dd 100644
--- a/apps/sim/ee/organization-usage/components/usage-summary.tsx
+++ b/apps/sim/ee/organization-usage/components/usage-summary.tsx
@@ -1,7 +1,7 @@
'use client'
import { useMemo } from 'react'
-import { Badge } from '@sim/emcn'
+import { Badge, cn } from '@sim/emcn'
import { BarChart } from '@/components/charts'
import type { OrganizationUsageSummary } from '@/lib/api/contracts/organization-usage'
import { formatCreditsLabel } from '@/lib/billing/credits/conversion'
@@ -16,6 +16,12 @@ interface UsageSummaryProps {
limitCredits?: number | null
isLoading: boolean
isError: boolean
+ /**
+ * Dims the figures while a re-keyed fetch resolves, rather than blanking them — the
+ * same treatment `UsageConsumers` gives a retained list. Without it the headline and
+ * chart present the previous period's numbers as though they were the new period's.
+ */
+ isPlaceholderData?: boolean
}
function percentDelta(current: number, previous: number): number | null {
@@ -23,7 +29,13 @@ function percentDelta(current: number, previous: number): number | null {
return ((current - previous) / previous) * 100
}
-export function UsageSummary({ summary, limitCredits, isLoading, isError }: UsageSummaryProps) {
+export function UsageSummary({
+ summary,
+ limitCredits,
+ isLoading,
+ isError,
+ isPlaceholderData,
+}: UsageSummaryProps) {
/*
Stabilized so `BarChart`'s `memo()` can actually pass. Built inline it was a new
array on every render of the panel — a date-picker toggle or an export click
@@ -52,7 +64,9 @@ export function UsageSummary({ summary, limitCredits, isLoading, isError }: Usag
const isOverLimit = hasLimit && used > limitCredits
return (
-
+
{/*
One line, and the allowance sits beside the figure rather than under it —
restating "4,958 credits used" below a "4,958 credits" headline said the same
diff --git a/apps/sim/hooks/queries/organization-usage.ts b/apps/sim/hooks/queries/organization-usage.ts
index 6eb65523ae5..1f514c9e568 100644
--- a/apps/sim/hooks/queries/organization-usage.ts
+++ b/apps/sim/hooks/queries/organization-usage.ts
@@ -26,22 +26,59 @@ export const ORGANIZATION_USAGE_EVENTS_STALE_TIME = 30 * 1000
const EVENTS_PAGE_SIZE = 50
+/**
+ * A usage key with its trailing segment dropped — the identity of the question being
+ * asked, which is what `placeholderData` has to compare on.
+ *
+ * Both keys put the one segment their placeholder may legitimately cross last: the
+ * summary's window ("the same scope, a different period") and the breakdown's row limit
+ * ("the same list, more rows"). Everything a retained answer must never cross —
+ * organization, workspace, dimension — sits in the prefix.
+ */
+function usageKeyIdentity(key: readonly unknown[]): string {
+ return hashKey(key.slice(0, -1))
+}
+
+interface UseSummaryOptions {
+ /** The panel fetches the drill-down's chart only while that view is open. */
+ enabled?: boolean
+ /** Narrows to one workspace, for the Workspaces drill-down. */
+ workspaceId?: string
+}
+
export function useOrganizationUsageSummary(
organizationId: string | undefined,
- window: OrganizationUsageWindowKey
+ window: OrganizationUsageWindowKey,
+ options: UseSummaryOptions = {}
) {
+ const { workspaceId } = options
+ const queryKey = organizationUsageKeys.summary(organizationId ?? '', window, workspaceId)
return useQuery({
- queryKey: organizationUsageKeys.summary(organizationId ?? '', window),
+ queryKey,
queryFn: ({ signal }): Promise =>
requestJson(getOrganizationUsageSummaryContract, {
params: { id: organizationId as string },
- query: { ...window },
+ query: { ...window, ...(workspaceId ? { workspaceId } : {}) },
signal,
}),
- enabled: Boolean(organizationId),
+ enabled: Boolean(organizationId) && (options.enabled ?? true),
staleTime: ORGANIZATION_USAGE_SUMMARY_STALE_TIME,
- // Changing the period should dim the current figures rather than blank them.
- placeholderData: keepPreviousData,
+ /**
+ * Kept only across a period change — the same scope asked about a different window,
+ * where dimming the figures beats blanking them.
+ *
+ * Not `keepPreviousData`, which retains across *any* key change: once the key
+ * carries a workspace, moving between two drill-downs would draw one workspace's
+ * headline, delta, and chart under the other's name until the fetch landed. A
+ * figure attributed to the wrong workspace is worse than a brief skeleton, and
+ * unlike a ranked list it carries nothing that would look out of place.
+ */
+ placeholderData: (previous, previousQuery) =>
+ previous &&
+ previousQuery &&
+ usageKeyIdentity(previousQuery.queryKey) === usageKeyIdentity(queryKey)
+ ? previous
+ : undefined,
})
}
@@ -53,14 +90,6 @@ interface UseBreakdownOptions {
workspaceId?: string
}
-/**
- * A breakdown key with its trailing row limit removed — the identity of the list,
- * which is what "the same list, more rows" has to compare on.
- */
-function breakdownListIdentity(key: readonly unknown[]): string {
- return hashKey(key.slice(0, -1))
-}
-
export function useOrganizationUsageBreakdown(
organizationId: string | undefined,
window: OrganizationUsageWindowKey,
@@ -101,7 +130,7 @@ export function useOrganizationUsageBreakdown(
placeholderData: (previous, previousQuery) =>
previous &&
previousQuery &&
- breakdownListIdentity(previousQuery.queryKey) === breakdownListIdentity(queryKey)
+ usageKeyIdentity(previousQuery.queryKey) === usageKeyIdentity(queryKey)
? previous
: undefined,
})
diff --git a/apps/sim/hooks/queries/utils/organization-usage-keys.ts b/apps/sim/hooks/queries/utils/organization-usage-keys.ts
index 3c147137bc0..0b0f4063f85 100644
--- a/apps/sim/hooks/queries/utils/organization-usage-keys.ts
+++ b/apps/sim/hooks/queries/utils/organization-usage-keys.ts
@@ -23,8 +23,25 @@ export interface OrganizationUsageWindowKey {
export const organizationUsageKeys = {
all: (organizationId: string) => ['organizations', 'detail', organizationId, 'usage'] as const,
- summary: (organizationId: string, window: OrganizationUsageWindowKey) =>
- [...organizationUsageKeys.all(organizationId), 'summary', window] as const,
+ summary: (
+ organizationId: string,
+ window: OrganizationUsageWindowKey,
+ /** Set only inside the Workspaces drill-down, whose chart reads one workspace. */
+ workspaceId?: string
+ ) =>
+ [
+ ...organizationUsageKeys.all(organizationId),
+ 'summary',
+ workspaceId ?? '',
+ /*
+ Last deliberately, as on `breakdown`: it is the one segment the summary's
+ `placeholderData` may cross, so the scope's identity is a plain prefix rather
+ than an index-based filter that would silently drop `workspaceId` if a segment
+ were ever appended. Ordering it the other way is what let a retained summary
+ cross workspaces.
+ */
+ window,
+ ] as const,
breakdowns: (organizationId: string, window: OrganizationUsageWindowKey) =>
[...organizationUsageKeys.all(organizationId), 'breakdown', window] as const,
breakdown: (
diff --git a/apps/sim/lib/api/contracts/organization-usage.ts b/apps/sim/lib/api/contracts/organization-usage.ts
index 658a9f040c1..5686c1ca61f 100644
--- a/apps/sim/lib/api/contracts/organization-usage.ts
+++ b/apps/sim/lib/api/contracts/organization-usage.ts
@@ -126,13 +126,24 @@ const organizationUsageWindowQuerySchema = z.object({
.default('UTC'),
})
-export const organizationUsageSummaryQuerySchema = organizationUsageWindowQuerySchema
+/**
+ * Narrows a read to one workspace, for the Workspaces drill-down.
+ *
+ * Declared once and spread into both query schemas: the drill-down draws its chart
+ * from the summary and its lists from the breakdown, so a workspace filter either
+ * surface could express alone is one the two could disagree about.
+ */
+const usageWorkspaceScopeShape = {
+ workspaceId: workspaceIdSchema.optional(),
+} as const
+
+export const organizationUsageSummaryQuerySchema =
+ organizationUsageWindowQuerySchema.extend(usageWorkspaceScopeShape)
export type OrganizationUsageSummaryQuery = z.input
export const organizationUsageBreakdownQuerySchema = organizationUsageWindowQuerySchema.extend({
+ ...usageWorkspaceScopeShape,
dimension: usageBreakdownDimensionSchema,
- /** Narrows the breakdown to one workspace, for the Workspaces drill-down. */
- workspaceId: workspaceIdSchema.optional(),
limit: usageLimitSchema(50, 10),
})
export type OrganizationUsageBreakdownQuery = z.input
diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts
index 8efa0811cfd..4e5ea54fea3 100644
--- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts
+++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts
@@ -78,8 +78,8 @@ export const getOrganizationUsageBreakdown = defineAuthorizedOrganizationUsageUs
customEnd: input.endDate,
timezone: input.timezone,
})
- const scope = buildUsageAnalyticsScope(context.billingEntity, window)
- const raw = await readUsageBreakdown(scope, input.dimension, input.workspaceId)
+ const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId)
+ const raw = await readUsageBreakdown(scope, input.dimension)
/**
* Re-key onto what the panel actually displays before ranking.
diff --git a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts
index 48616c6f1a9..611f229be8e 100644
--- a/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts
+++ b/apps/sim/lib/billing/application/organization-usage/get-organization-usage-summary.ts
@@ -20,6 +20,8 @@ export interface OrganizationUsageSummaryInput {
startDate?: Date
endDate?: Date
timezone: string
+ /** Narrows to one workspace, for the Workspaces drill-down. */
+ workspaceId?: string
}
export interface OrganizationUsageSummaryResult {
@@ -47,7 +49,7 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
timezone: input.timezone,
})
const bucket = resolveUsageBucket(window)
- const scope = buildUsageAnalyticsScope(context.billingEntity, window)
+ const scope = buildUsageAnalyticsScope(context.billingEntity, window, input.workspaceId)
/**
* The comparison window only exists when it is exactly derivable.
@@ -68,7 +70,11 @@ export const getOrganizationUsageSummary = defineAuthorizedOrganizationUsageUseC
readUsageTotals(scope),
readUsageTimeSeries(scope, bucket, input.timezone),
comparison
- ? readUsageTotals(buildUsageAnalyticsScope(context.billingEntity, comparison))
+ ? readUsageTotals(
+ // Same narrowing as the current window, or the delta would compare one
+ // workspace against the whole organization.
+ buildUsageAnalyticsScope(context.billingEntity, comparison, input.workspaceId)
+ )
: Promise.resolve(null),
])
diff --git a/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts b/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts
index 173e15826b5..22120ef90ff 100644
--- a/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts
+++ b/apps/sim/lib/billing/application/organization-usage/organization-usage-use-cases.test.ts
@@ -125,4 +125,30 @@ describe('organization usage authorization', () => {
expect(JSON.stringify(totalsScope)).toContain(ORG)
expect(JSON.stringify(seriesScope)).toBe(JSON.stringify(totalsScope))
})
+
+ it('narrows the drill-down’s chart and its comparison window to the same workspace', async () => {
+ /*
+ A reporting period, so the delta's read actually happens: `resolvePreviousPeriod`
+ returns null for a stripe period, and against the default subscription above this
+ test would assert the narrowing of a query that was never issued.
+ */
+ mocks.getOrganizationSubscription.mockResolvedValue({
+ plan: 'enterprise',
+ metadata: { reportingPeriodAnchorDate: '2026-01-01', reportingPeriodInterval: 'month' },
+ })
+
+ await getOrganizationUsageSummary.execute({
+ principal: session,
+ input: { ...input, workspaceId: 'ws-1' },
+ })
+
+ // The current window and the previous one, both narrowed. Narrowing only the
+ // current window measures one workspace against the whole organization and
+ // renders the difference as that workspace's own trend.
+ expect(mocks.readUsageTotals).toHaveBeenCalledTimes(2)
+ for (const [scope] of mocks.readUsageTotals.mock.calls) {
+ expect(JSON.stringify(scope)).toContain('ws-1')
+ }
+ expect(JSON.stringify(mocks.readUsageTimeSeries.mock.calls[0][0])).toContain('ws-1')
+ })
})
diff --git a/apps/sim/lib/billing/core/usage-analytics-queries.ts b/apps/sim/lib/billing/core/usage-analytics-queries.ts
index fec2b8e0480..f804a83ab66 100644
--- a/apps/sim/lib/billing/core/usage-analytics-queries.ts
+++ b/apps/sim/lib/billing/core/usage-analytics-queries.ts
@@ -120,13 +120,10 @@ function breakdownColumn(dimension: UsageBreakdownDimension) {
export async function readUsageBreakdown(
scope: SQL[],
dimension: UsageBreakdownDimension,
- /** Narrows to one workspace, for the Workspaces drill-down. */
- workspaceId: string | undefined,
executor: DbClient = dbReplica
): Promise {
const column = breakdownColumn(dimension)
const conditions = [...scope]
- if (workspaceId) conditions.push(eq(usageLog.workspaceId, workspaceId))
/**
* `description` holds a model name only for the model categories; a tool or fixed
* row would otherwise appear as a phantom "model". The two model dimensions split
diff --git a/apps/sim/lib/billing/core/usage-analytics.test.ts b/apps/sim/lib/billing/core/usage-analytics.test.ts
index c288c8e62ec..3fe079040e6 100644
--- a/apps/sim/lib/billing/core/usage-analytics.test.ts
+++ b/apps/sim/lib/billing/core/usage-analytics.test.ts
@@ -68,6 +68,33 @@ describe('buildUsageAnalyticsScope', () => {
expect(shape).toContain('usageLog.billingEntityType')
expect(shape).toContain('usageLog.billingEntityId')
})
+
+ it('narrows to a workspace in every window shape', () => {
+ // Each branch returns its own array, so a narrowing added to only one of them is a
+ // drill-down that quietly reports the whole organization under the other two.
+ const windows = [
+ { kind: 'period', period: period({ source: 'stripe' }) },
+ {
+ kind: 'period',
+ period: period({ source: 'reporting', anchorDate: '2026-08-01', interval: 'month' }),
+ },
+ {
+ kind: 'range',
+ from: new Date('2026-08-01T00:00:00.000Z'),
+ to: new Date('2026-08-08T00:00:00.000Z'),
+ },
+ ] as const
+
+ for (const window of windows) {
+ expect(JSON.stringify(buildUsageAnalyticsScope(ENTITY, window, 'ws-1'))).toContain(
+ 'usageLog.workspaceId'
+ )
+ }
+ })
+
+ it('leaves the scope organization-wide when no workspace is given', () => {
+ expect(scopeShape({ kind: 'period', period: period() })).not.toContain('usageLog.workspaceId')
+ })
})
describe('usageWindowLedgerFilter', () => {
diff --git a/apps/sim/lib/billing/core/usage-analytics.ts b/apps/sim/lib/billing/core/usage-analytics.ts
index 07533fe0ac6..d50a71dbafa 100644
--- a/apps/sim/lib/billing/core/usage-analytics.ts
+++ b/apps/sim/lib/billing/core/usage-analytics.ts
@@ -65,12 +65,28 @@ export type UsageAnalyticsWindow =
*/
export function buildUsageAnalyticsScope(
entity: BillingEntity,
- window: UsageAnalyticsWindow
+ window: UsageAnalyticsWindow,
+ /**
+ * Narrows every read built from this scope to one workspace, for the Workspaces
+ * drill-down.
+ *
+ * On the scope rather than on each query: the drill-down draws a chart, a headline,
+ * and two ranked lists from separate reads, and a narrowing each one applied for
+ * itself is one they could apply differently. Not an authorization boundary — the
+ * entity predicates above are — so a workspace belonging to another organization
+ * narrows to nothing rather than disclosing anything.
+ *
+ * `workspace_id` is not in `usage_log_billing_entity_created_at_cost_idx`, so a
+ * narrowed read heap-fetches per row in the window. That is the cost the Workspaces
+ * tab already pays to rank its list, and it is only ever paid inside a drill-down.
+ */
+ workspaceId?: string
): SQL[] {
const conditions: SQL[] = [
eq(usageLog.billingEntityType, entity.type),
eq(usageLog.billingEntityId, entity.id),
]
+ if (workspaceId) conditions.push(eq(usageLog.workspaceId, workspaceId))
if (window.kind === 'range') {
conditions.push(gte(usageLog.createdAt, window.from), lt(usageLog.createdAt, window.to))