diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md
index 4915865cc8a..c39babca114 100644
--- a/.claude/rules/sim-settings-pages.md
+++ b/.claude/rules/sim-settings-pages.md
@@ -2,6 +2,7 @@
description: Settings page layout and SettingsPanel design-system compliance
paths:
- "apps/sim/app/workspace/*/settings/**"
+ - "apps/sim/app/o/*/settings/**"
- "apps/sim/app/workspace/*/{integrations,skills,upgrade}/**"
- "apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**"
- "apps/sim/components/{settings,permissions}/**"
@@ -10,14 +11,14 @@ paths:
# Settings Pages
-The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
+Workspace and organization `settings/[section]/layout.tsx` own page chrome via
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
by a **title + description from navigation metadata**. The chrome stays mounted
across section navigation. Its routed title and description are available before
the section body resolves. Each section renders through the **`SettingsPanel`**
registrar
-(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
+(`@/components/settings/settings-panel`), which feeds
the shell its header data and renders only the section body. Sections supply
**data**, never chrome.
@@ -34,7 +35,7 @@ shell (fed through `SettingsPanel`):
## Canonical page shape
```tsx
-import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
+import { SettingsPanel } from '@/components/settings/settings-panel'
return (
` or
- ``.
+ muted status message for empty lists, "no results", loading gates, and failed
+ reads. `variant='fill'` (default) centers in the available height;
+ `variant='inline'` sits in flow. Never hand-roll its layout.
+- **`SettingsQueryErrorState`** (same module) — the muted read-failure message
+ with an accessible alert and retry control. Show an initial failure when data
+ is unavailable; preserve cached content, mounted editors, and unsaved drafts
+ during background failures, using an inline status. Validation and mutation
+ failures retain their error styling.
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
actions menu for a list row. Pass `label` (aria-label) and
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
@@ -395,6 +399,6 @@ A settings page is design-system-clean when:
- [ ] Every **resource** list row (a thing with an identity — a tool, a server, a key, a credential) is a `SettingsResourceRow` in a `RESOURCE_LIST_STACK`/`RESOURCE_LIST_GRID` — no wrapper `
`/` `, no hand-passed arrow, no re-derived title/subtitle spans. Rows with a genuinely different shape stay bespoke, and draw their own arrow with `RESOURCE_ROW_ARROW_CLASSES`: multi-line bodies (inbox tasks), tabular columns (billing invoices, credit usage), grids (secrets), and the member rows (see the avatar note above).
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
- [ ] Decorative trailing content is in `badge`, not `trailing`.
-- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
+- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/read-failure messages use `SettingsEmptyState`, with `SettingsQueryErrorState` when offering retry.
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
- [ ] The local gate in the root `CLAUDE.md` ("How your work is checked") passes.
diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc
index d594f0b3ccf..aeb87b3325a 100644
--- a/.cursor/rules/sim-settings-pages.mdc
+++ b/.cursor/rules/sim-settings-pages.mdc
@@ -1,20 +1,20 @@
---
description: "Settings page layout and SettingsPanel design-system compliance"
-globs: ["apps/sim/app/workspace/*/settings/**","apps/sim/app/workspace/*/{integrations,skills,upgrade}/**","apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**","apps/sim/components/{settings,permissions}/**","apps/sim/ee/**/components/**"]
+globs: ["apps/sim/app/workspace/*/settings/**","apps/sim/app/o/*/settings/**","apps/sim/app/workspace/*/{integrations,skills,upgrade}/**","apps/sim/app/workspace/*/components/{resource-tile,credential-detail}/**","apps/sim/components/{settings,permissions}/**","apps/sim/ee/**/components/**"]
---
# Settings Pages
-The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via
+Workspace and organization `settings/[section]/layout.tsx` own page chrome via
`SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned
action chips), a scroll region, and a centered `max-w-[48rem]` content column led
by a **title + description from navigation metadata**. The chrome stays mounted
across section navigation. Its routed title and description are available before
the section body resolves. Each section renders through the **`SettingsPanel`**
registrar
-(`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds
+(`@/components/settings/settings-panel`), which feeds
the shell its header data and renders only the section body. Sections supply
**data**, never chrome.
@@ -31,7 +31,7 @@ shell (fed through `SettingsPanel`):
## Canonical page shape
```tsx
-import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
+import { SettingsPanel } from '@/components/settings/settings-panel'
return (
` or
- ``.
+ muted status message for empty lists, "no results", loading gates, and failed
+ reads. `variant='fill'` (default) centers in the available height;
+ `variant='inline'` sits in flow. Never hand-roll its layout.
+- **`SettingsQueryErrorState`** (same module) — the muted read-failure message
+ with an accessible alert and retry control. Show an initial failure when data
+ is unavailable; preserve cached content, mounted editors, and unsaved drafts
+ during background failures, using an inline status. Validation and mutation
+ failures retain their error styling.
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
actions menu for a list row. Pass `label` (aria-label) and
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
@@ -392,6 +395,6 @@ A settings page is design-system-clean when:
- [ ] Every **resource** list row (a thing with an identity — a tool, a server, a key, a credential) is a `SettingsResourceRow` in a `RESOURCE_LIST_STACK`/`RESOURCE_LIST_GRID` — no wrapper ``/` `, no hand-passed arrow, no re-derived title/subtitle spans. Rows with a genuinely different shape stay bespoke, and draw their own arrow with `RESOURCE_ROW_ARROW_CLASSES`: multi-line bodies (inbox tasks), tabular columns (billing invoices, credit usage), grids (secrets), and the member rows (see the avatar note above).
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
- [ ] Decorative trailing content is in `badge`, not `trailing`.
-- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
+- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/read-failure messages use `SettingsEmptyState`, with `SettingsQueryErrorState` when offering retry.
- [ ] Delete is a plain `id:'delete'` header action behind a `ChipConfirmModal`; `destructive` is reserved for bulk actions.
- [ ] The local gate in the root `CLAUDE.md` ("How your work is checked") passes.
diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts
index 2223cd89c70..765240241a7 100644
--- a/apps/sim/app/api/billing/route.ts
+++ b/apps/sim/app/api/billing/route.ts
@@ -10,6 +10,7 @@ import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getBillingContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
+import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
import { getSession } from '@/lib/auth'
import { getOrganizationSubscription, getPersonalBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
@@ -125,6 +126,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
getOrganizationBillingData(organizationId, dbReplica, {
limit: memberLimit,
offset: memberOffset,
+ includeMemberUsageCounts: false,
}),
getOrganizationSubscription(organizationId, { executor: dbReplica, onError: 'throw' }),
dbReplica
@@ -218,6 +220,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error,
})
+ const recoverable = internalBillingReadErrorPolicy.project(error)
+ if (recoverable)
+ return NextResponse.json(recoverable.body, {
+ status: recoverable.status,
+ headers: recoverable.headers,
+ })
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/organizations/[id]/billing-plan/route.ts b/apps/sim/app/api/organizations/[id]/billing-plan/route.ts
new file mode 100644
index 00000000000..8325b44cf68
--- /dev/null
+++ b/apps/sim/app/api/organizations/[id]/billing-plan/route.ts
@@ -0,0 +1,24 @@
+import { getOrganizationPlanSeatsContract } from '@/lib/api/contracts/organization'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
+import { getOrganizationPlanSeats } from '@/lib/billing/application/organization-billing/get-organization-plan-seats'
+import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'
+
+export const dynamic = 'force-dynamic'
+
+export const GET = defineInternalJsonRoute({
+ contract: getOrganizationPlanSeatsContract,
+ auth: internalSessionAuth,
+ operation: organizationBillingOperations.planSeats,
+ rateLimit: internalRateLimits.none({
+ reason: 'Small organization plan and seat read restricted to current admins and owners',
+ }),
+ errorPolicy: internalBillingReadErrorPolicy,
+ mapInput: ({ params }) => ({ organizationId: params.id }),
+ useCase: getOrganizationPlanSeats,
+ present: (data) => ({ success: true, data }),
+})
diff --git a/apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts b/apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts
index 72423614ef8..3d1638de07b 100644
--- a/apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts
+++ b/apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts
@@ -6,15 +6,12 @@ const { mockReadBillingSummary } = vi.hoisted(() => ({
mockReadBillingSummary: vi.fn(),
}))
-vi.mock(
- '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary',
- () => ({
- getOrganizationBillingSummary: {
- operation: { id: 'organization_billing.summary.read' },
- execute: mockReadBillingSummary,
- },
- })
-)
+vi.mock('@/lib/billing/application/organization-billing/get-organization-billing-summary', () => ({
+ getOrganizationBillingSummary: {
+ operation: { id: 'organization_billing.summary.read' },
+ execute: mockReadBillingSummary,
+ },
+}))
import { GET } from '@/app/api/organizations/[id]/billing-summary/route'
diff --git a/apps/sim/app/api/organizations/[id]/billing-summary/route.ts b/apps/sim/app/api/organizations/[id]/billing-summary/route.ts
index 778387f37d8..2528b3f5f91 100644
--- a/apps/sim/app/api/organizations/[id]/billing-summary/route.ts
+++ b/apps/sim/app/api/organizations/[id]/billing-summary/route.ts
@@ -1,23 +1,23 @@
import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization'
import {
defineInternalJsonRoute,
- internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
-import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
-import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations'
+import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
+import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing/get-organization-billing-summary'
+import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'
export const dynamic = 'force-dynamic'
export const GET = defineInternalJsonRoute({
contract: getOrganizationBillingSummaryContract,
auth: internalSessionAuth,
- operation: organizationBillingSummaryOperations.read,
+ operation: organizationBillingOperations.read,
rateLimit: internalRateLimits.none({
reason: 'Authenticated organization billing read, restricted to organization admins and owners',
}),
- errorPolicy: internalOrchestrationErrorPolicy,
+ errorPolicy: internalBillingReadErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: getOrganizationBillingSummary,
present: (data) => ({ success: true, data }),
diff --git a/apps/sim/app/api/usage/route.ts b/apps/sim/app/api/usage/route.ts
index 30832a623e7..1130ba9ed2b 100644
--- a/apps/sim/app/api/usage/route.ts
+++ b/apps/sim/app/api/usage/route.ts
@@ -8,6 +8,7 @@ import {
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
+import { internalBillingReadErrorPolicy } from '@/lib/api/server/routes/billing-read'
import {
readUsageLimit,
type UsageLimitResult,
@@ -48,7 +49,7 @@ export const GET = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: usageLimitOperations.read,
rateLimit,
- errorPolicy,
+ errorPolicy: internalBillingReadErrorPolicy,
mapInput: ({ query }) => query,
useCase: readUsageLimit,
present,
diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-recently-deleted.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-recently-deleted.tsx
index f11fdd87da4..51082068ba5 100644
--- a/apps/sim/app/o/[organizationId]/settings/components/organization-recently-deleted.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/components/organization-recently-deleted.tsx
@@ -67,7 +67,7 @@ export function OrganizationRecentlyDeleted({ organizationId }: OrganizationRece
search={{ value: search, onChange: setSearch, placeholder: 'Search deleted chats' }}
>
{error ? (
- {error.message}
+ {error.message}
) : isLoading ? null : filtered.length === 0 ? (
{searchTerm && chats.length > 0 ? 'No chats match your search' : 'No deleted chats'}
diff --git a/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts b/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts
index bcec5b288a0..a22ed11dd4c 100644
--- a/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts
+++ b/apps/sim/app/o/[organizationId]/settings/settings-query-warmers.ts
@@ -4,11 +4,11 @@ import type {
OrganizationSettingsSection,
} from '@/components/settings/navigation'
import {
- organizationBillingQueryOptions,
organizationDetailQueryOptions,
organizationRosterQueryOptions,
} from '@/hooks/queries/organization'
import { organizationBillingSummaryOptions } from '@/hooks/queries/organization-billing-summary'
+import { organizationPlanSeatsOptions } from '@/hooks/queries/organization-plan-seats'
import { prefetchQueryOnIntent } from '@/hooks/queries/utils/prefetch-query-on-intent'
interface OrganizationSettingsQueryWarmContext {
@@ -28,7 +28,7 @@ export function warmOrganizationSettingsSectionQuery(
prefetchQueryOnIntent(queryClient, organizationDetailQueryOptions(organizationId))
prefetchQueryOnIntent(queryClient, organizationRosterQueryOptions(organizationId))
if (isAdmin) {
- prefetchQueryOnIntent(queryClient, organizationBillingQueryOptions(organizationId))
+ prefetchQueryOnIntent(queryClient, organizationPlanSeatsOptions(organizationId))
}
}
if (section === 'billing' && isAdmin) {
diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
index f2efc1446eb..3645f6d8ac8 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx
@@ -21,7 +21,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useSendWorkspaceInvitations } from '@/hooks/queries/invitations'
-import { useOrganizationBilling } from '@/hooks/queries/organization'
+import { useOrganizationPlanSeats } from '@/hooks/queries/organization-plan-seats'
import { useAdminWorkspaces } from '@/hooks/queries/workspace'
const logger = createLogger('InviteModal')
@@ -193,7 +193,7 @@ export function InviteModal({
)
const canViewOrganizationBilling = canGrantOrganizationAdmin
- const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
+ const { data: organizationBillingData } = useOrganizationPlanSeats(organizationId ?? '', {
enabled: open && billingEnabled && canViewOrganizationBilling,
})
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx
index 0bebc964de0..478c59d504d 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-documents/connector-documents.tsx
@@ -201,9 +201,9 @@ export function ConnectorDocuments({
{mutationError && (
-
+
{mutationError.message}
-
+
)}
>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx
index 14e4a1b464d..132073f2e75 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions.tsx
@@ -17,7 +17,6 @@ import {
import { MoreHorizontal } from '@sim/emcn/icons'
import { orderHeaderActions, type SettingsAction } from '@/components/settings/settings-header'
import type { ConnectorActionState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/use-connector-actions'
-import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
interface ConnectorActionsProps {
state: ConnectorActionState
@@ -105,9 +104,9 @@ export function ConnectorActionFeedback({ state }: ConnectorActionFeedbackProps)
return (
<>
{state.error && (
-
+
{state.error.message}
-
+
)}
Loading usage…
) : hasBlockingError ? (
-
- Couldn't load credit usage.
-
+ Couldn't load credit usage.
) : logs.length === 0 ? (
No credit usage in this period.
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
index e6462440f7f..fa4ad0f77cf 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
@@ -418,7 +418,7 @@ export function Admin() {
{usersError && (
-
+
{getErrorMessage(usersError, 'Failed to fetch users')}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
index b8dc9cd6d30..96d285f82e3 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx
@@ -245,7 +245,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
actions={actions}
>
{apiKeysError && apiKeysData === undefined ? (
-
+
{getErrorMessage(apiKeysError, 'Failed to load API keys')}
) : isLoading ? null : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
index 08ae660953d..bb7299df95d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx
@@ -548,7 +548,7 @@ export function BYOK() {
}
>
{keysError && activeQueryData === undefined ? (
-
+
{getErrorMessage(keysError, 'Failed to load provider keys')}
) : (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
index d6ac817c5c1..ece6809b780 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
@@ -123,9 +123,7 @@ export function CustomTools() {
actions={actions}
>
{error ? (
-
- {getErrorMessage(error, 'Failed to load tools')}
-
+ {getErrorMessage(error, 'Failed to load tools')}
) : isLoading ? null : showEmptyState ? (
{canEdit ? 'Click "Add tool" above to get started' : 'No custom tools configured'}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
index fdbac9319a5..35cd9525067 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
@@ -30,7 +30,7 @@ export function Inbox() {
if (error && config === undefined) {
return (
-
+
{getErrorMessage(error, 'Failed to load Inbox settings')}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
index 488a0ef206c..3394e9e8bdf 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
@@ -694,7 +694,7 @@ export function MCP() {
}
>
{listError ? (
-
+
{getErrorMessage(listError, 'Failed to load MCP servers')}
) : serversLoading ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
index 89d8448b4d7..e2d2d55cc16 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -548,7 +548,7 @@ export function RecentlyDeleted() {
/>
{error ? (
-
+
{toError(error).message || 'Failed to load deleted items'}
) : isLoading && filtered.length === 0 ? null : filtered.length === 0 ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx
index b7cc4656887..e22358f3039 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes.tsx
@@ -239,7 +239,7 @@ export function Sandboxes() {
if (error && data === undefined) {
return (
-
+
{getErrorMessage(error, 'Failed to load sandboxes')}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx
index cb1ab6b89e7..9e57283a49c 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-empty-state/settings-empty-state.tsx
@@ -10,8 +10,6 @@ interface SettingsEmptyStateProps {
* matched nothing. Defaults to `fill`.
*/
variant?: 'fill' | 'inline'
- /** Renders the message in the error tone, for a failed load. */
- tone?: 'muted' | 'error'
}
interface SettingsQueryErrorStateProps {
@@ -27,16 +25,11 @@ interface SettingsQueryErrorStateProps {
* "no results", and entitlement/loading gates. Centralizes the text token and
* spacing so every settings page reads identically.
*/
-export function SettingsEmptyState({
- children,
- variant = 'fill',
- tone = 'muted',
-}: SettingsEmptyStateProps) {
+export function SettingsEmptyState({ children, variant = 'fill' }: SettingsEmptyStateProps) {
return (
@@ -53,7 +46,7 @@ export function SettingsQueryErrorState({
variant,
}: SettingsQueryErrorStateProps) {
return (
-
+
{getErrorMessage(error, fallback)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
index db0fbb21782..11e24b4e77e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx
@@ -29,11 +29,11 @@ import {
useCreateOrganization,
useMemberRemovalImpact,
useOrganization,
- useOrganizationBilling,
useOrganizationRoster,
useRemoveMember,
useTransferOwnership,
} from '@/hooks/queries/organization'
+import { useOrganizationPlanSeats } from '@/hooks/queries/organization-plan-seats'
import { useOpenBillingPortal, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -92,7 +92,7 @@ export function TeamManagement({
isFetchedAfterMount: isOrganizationBillingFetchedAfterMount,
isFetching: isOrganizationBillingFetching,
refetch: refetchOrganizationBilling,
- } = useOrganizationBilling(organizationId, { enabled: billingEnabled && adminOrOwner })
+ } = useOrganizationPlanSeats(organizationId, { enabled: billingEnabled && adminOrOwner })
const {
data: roster,
@@ -168,14 +168,11 @@ export function TeamManagement({
const pendingSeats = Math.max(0, reservedSeats - usedSeats)
/**
- * The org's active subscription, derived from DB-backed organization billing
- * (`getOrganizationBillingData` only returns data when an entitled org
- * subscription exists). We intentionally do not read this from better-auth's
- * `client.subscription.list`, which does not reliably surface org-scoped
- * subscriptions.
+ * Organization plan metadata comes from the primary database; Better Auth's
+ * `client.subscription.list` does not reliably surface org-scoped subscriptions.
*/
const orgBilling = billingEnabled ? (organizationBillingData?.data ?? null) : null
- const orgSubscription = orgBilling
+ const orgSubscription = orgBilling?.subscriptionPlan
? {
id: orgBilling.organizationId,
plan: orgBilling.subscriptionPlan,
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
index f712133d9b9..5320c8e3410 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx
@@ -387,7 +387,7 @@ function ServerDetailView({
if (error || !data) {
return (
- Failed to load server details
+ Failed to load server details
)
}
@@ -1040,7 +1040,7 @@ export function WorkflowMcpServers() {
>
{error ? (
-
+
{getErrorMessage(error, 'Failed to load MCP servers')}
) : isLoading ? null : !hasServers ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx
index 7f602da7e00..dbeb51ad2af 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx
@@ -102,11 +102,7 @@ export function SecretReferencesPanel({
}
if (isError) {
- return (
-
- Could not load references.
-
- )
+ return Could not load references.
}
if (isPending) {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx
index 670b4c80e39..c6137a5fde3 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel/secret-usage-panel.tsx
@@ -103,11 +103,7 @@ export function SecretUsagePanel({ workspaceId, secretName, scope }: SecretUsage
)
if (isError) {
- return (
-
- Could not load usage.
-
- )
+ return Could not load usage.
}
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
index 406561b3547..3b46902192d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx
@@ -161,7 +161,7 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
if (error && !(isApiClientError(error) && error.status === 404)) {
return (
-
+
{isApiClientError(error) && error.status === 403
? 'You do not have access to this secret.'
: 'Could not load this secret.'}
diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
index 07f67d865e4..48066011ab5 100644
--- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx
@@ -102,7 +102,7 @@ export function Skills() {
{error ? (
-
+
{getErrorMessage(error, 'Failed to load skills')}
) : filteredSkills.length > 0 ? (
diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx
index 04ce03102d8..13a943fc8dc 100644
--- a/apps/sim/ee/access-control/components/access-control.tsx
+++ b/apps/sim/ee/access-control/components/access-control.tsx
@@ -18,8 +18,6 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import { useQueryState } from 'nuqs'
-import { isEnterprise } from '@/lib/billing/plan-helpers'
-import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import {
groupIdParam,
groupIdUrlKeys,
@@ -47,9 +45,7 @@ import {
useCreatePermissionGroup,
useOrganizationWorkspaces,
usePermissionGroups,
- useUserPermissionConfig,
} from '@/ee/access-control/hooks/permission-groups'
-import { useOrganizationBilling } from '@/hooks/queries/organization'
const logger = createLogger('AccessControl')
@@ -65,57 +61,21 @@ export function AccessControl({
requestsHref,
}: AccessControlProps) {
const params = useParams()
- const { features } = useDeploymentShape()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
- /**
- * Access control is governed by the workspace's OWNING organization, which may
- * differ from the caller's active org (e.g. external members). Resolve the org
- * id and the caller's admin status server-side from the workspace so gating is
- * never keyed off the session's active org.
- */
- const {
- data: userPermissionConfig,
- isPending: entitlementLoading,
- error: entitlementError,
- } = useUserPermissionConfig(workspaceId)
- const {
- data: organizationBillingData,
- isPending: organizationBillingLoading,
- error: organizationBillingError,
- } = useOrganizationBilling(organizationId, {
- enabled: !features.accessControl && !userPermissionConfig?.entitled,
- })
- const currentUserIsOrgAdmin = isOrganizationAdmin
-
const {
- data: permissionGroups = [],
+ data: permissionGroupsData,
isPending: groupsLoading,
error: groupsError,
isFetching: groupsFetching,
refetch: refetchGroups,
- } = usePermissionGroups(organizationId, !!organizationId && currentUserIsOrgAdmin)
+ } = usePermissionGroups(organizationId, !!organizationId && isOrganizationAdmin)
+ const permissionGroups = permissionGroupsData ?? []
const { data: organizationWorkspaces = [], isPending: workspacesLoading } =
- useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin)
+ useOrganizationWorkspaces(organizationId, !!organizationId && isOrganizationAdmin)
- /**
- * Must be the resolved flag, not the raw `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`
- * read. The settings nav decides visibility from the same resolver, so
- * reading the bare var here let a deployment with only `ENTERPRISE_ENABLED`
- * set show the section and then refuse to manage it.
- */
- const isEntitled =
- features.accessControl ||
- !!userPermissionConfig?.entitled ||
- isEnterprise(organizationBillingData?.data?.subscriptionPlan)
- const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId
- const organizationEntitlementLoading =
- !features.accessControl && !userPermissionConfig?.entitled && organizationBillingLoading
-
- const isLoading =
- (workspaceId ? entitlementLoading : false) ||
- organizationEntitlementLoading ||
- (!!organizationId && currentUserIsOrgAdmin && groupsLoading)
+ const canManage = isOrganizationAdmin && !!organizationId
+ const isLoading = canManage && groupsLoading
const createPermissionGroup = useCreatePermissionGroup()
@@ -225,22 +185,24 @@ export function AccessControl({
},
]
+ const groupsErrorState = groupsError ? (
+ void refetchGroups()}
+ variant={permissionGroupsData === undefined ? 'fill' : 'inline'}
+ />
+ ) : null
+
if (isLoading) {
return
}
- const entitlementLoadError = isEntitled
- ? null
- : ((userPermissionConfig === undefined ? entitlementError : null) ??
- (organizationBillingData === undefined ? organizationBillingError : null))
- if (entitlementLoadError) {
- return (
-
- {getErrorMessage(entitlementLoadError, 'Failed to load Access Control access')}
-
- )
- }
-
if (!canManage) {
return (
@@ -251,38 +213,33 @@ export function AccessControl({
)
}
- if (groupsError) {
- return (
-
- void refetchGroups()}
- />
-
- )
+ if (groupsError && permissionGroupsData === undefined) {
+ return {groupsErrorState}
}
if (selectedGroup && organizationId) {
return (
-
+ <>
+ {groupsErrorState}
+
+ >
)
}
return (
<>
+ {groupsErrorState}
Review requests}
diff --git a/apps/sim/ee/access-control/components/group-detail.test.tsx b/apps/sim/ee/access-control/components/group-detail.test.tsx
index fd82f0b8f7e..31fec2fc633 100644
--- a/apps/sim/ee/access-control/components/group-detail.test.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.test.tsx
@@ -4,7 +4,8 @@ import { act, type ReactNode } from 'react'
import { createDeferred } from '@sim/testing/helpers/deferred'
import { jsonResponse } from '@sim/testing/helpers/http'
import { authClientMock } from '@sim/testing/mocks/auth-client.mock'
-import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
+import { setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
+import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
import { providersModelsMock } from '@sim/testing/mocks/providers-models.mock'
import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
@@ -13,6 +14,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PermissionGroup } from '@/lib/api/contracts/permission-groups'
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
+import { AccessControl } from '@/ee/access-control/components/access-control'
import { GroupDetail } from '@/ee/access-control/components/group-detail'
import { allowedProvidersKeys } from '@/hooks/queries/allowed-providers'
import { organizationKeys } from '@/hooks/queries/utils/organization-keys'
@@ -106,6 +108,62 @@ async function settle() {
})
}
+describe('permission group settings availability', () => {
+ it.each([{}, { workspaceId: 'workspace-1' }])(
+ 'edits groups without reading billing and retains the draft through a failed refresh (%j)',
+ async (params) => {
+ setEnvFlags({ isHosted: true, isBillingEnabled: true, isAccessControlEnabled: false })
+ nextNavigationMockFns.mockUseParams.mockReturnValue(params)
+ client.setQueryData(permissionGroupKeys.list('org-1'), [group])
+ client.setQueryData(permissionGroupKeys.orgWorkspaces('org-1'), [])
+ client.setQueryData(allowedProvidersKeys.blacklisted(), { blacklistedProviders: [] })
+ const requests: string[] = []
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string) => {
+ requests.push(url)
+ return jsonResponse({ error: 'Service temporarily unavailable' }, 503)
+ })
+ )
+ act(() =>
+ root.render(
+
+
+
+
+
+ )
+ )
+ await settle()
+ const toggle = container.querySelector('#provider-openai')
+ if (!toggle) throw new Error('Authorized group editor was blocked by an unrelated read')
+ act(() => toggle.click())
+ let left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(false)
+ await act(async () => {
+ await client.invalidateQueries({ queryKey: permissionGroupKeys.list('org-1') })
+ })
+ await settle()
+ const refreshedToggle = container.querySelector('#provider-openai')
+ if (!refreshedToggle) throw new Error('Group editor disappeared after refresh')
+ expect(refreshedToggle.getAttribute('aria-checked')).toBe('false')
+ left = false
+ act(() =>
+ useSettingsDirtyStore.getState().requestLeave(() => {
+ left = true
+ })
+ )
+ expect(left).toBe(false)
+ expect(requests.some((url) => url.startsWith('/api/billing'))).toBe(false)
+ }
+ )
+})
+
describe('provider permission policy availability', () => {
it('stops warning after restoring the same provider membership in a different order', async () => {
providersUtilsMockFns.mockGetAllProviderIds.mockReturnValue(['openai', 'anthropic', 'google'])
diff --git a/apps/sim/ee/access-control/components/project-issue-restrictions.tsx b/apps/sim/ee/access-control/components/project-issue-restrictions.tsx
index 689ab44738c..48babc2df8e 100644
--- a/apps/sim/ee/access-control/components/project-issue-restrictions.tsx
+++ b/apps/sim/ee/access-control/components/project-issue-restrictions.tsx
@@ -48,7 +48,7 @@ export function ProjectIssueRestrictions({
}
>
{projects.error && (
- {projects.error.message}
+ {projects.error.message}
)}
{choices.map((project) => (
diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts
index ba0bbd699d2..29ae07c29e5 100644
--- a/apps/sim/ee/access-control/hooks/permission-groups.ts
+++ b/apps/sim/ee/access-control/hooks/permission-groups.ts
@@ -27,6 +27,7 @@ import {
PERMISSION_GROUPS_STALE_TIME,
permissionGroupKeys,
} from '@/hooks/queries/utils/permission-group-keys'
+import { shouldRetrySettingsRead } from '@/hooks/queries/utils/settings-read-retry'
export type {
PermissionGroup,
@@ -48,6 +49,8 @@ export function usePermissionGroups(organizationId?: string, enabled = true) {
},
enabled: Boolean(organizationId) && enabled,
staleTime: PERMISSION_GROUPS_STALE_TIME,
+ retry: shouldRetrySettingsRead,
+ retryOnMount: true,
})
}
diff --git a/apps/sim/ee/access-requests/components/access-request-review.tsx b/apps/sim/ee/access-requests/components/access-request-review.tsx
index bda96ad2607..1ae1d43b5c0 100644
--- a/apps/sim/ee/access-requests/components/access-request-review.tsx
+++ b/apps/sim/ee/access-requests/components/access-request-review.tsx
@@ -86,7 +86,7 @@ export function AccessRequestReview({
)}
{preview.isError && (
- {preview.error.message}
+ {preview.error.message}
void preview.refetch()}>Try again
)}
diff --git a/apps/sim/ee/access-requests/components/my-access-request-details.tsx b/apps/sim/ee/access-requests/components/my-access-request-details.tsx
index 1cc9cc1d181..0cbf0363595 100644
--- a/apps/sim/ee/access-requests/components/my-access-request-details.tsx
+++ b/apps/sim/ee/access-requests/components/my-access-request-details.tsx
@@ -46,7 +46,7 @@ export function MyAccessRequestDetails({ scope, requestId, onClose }: MyAccessRe
)}
{details.isError && (
- {details.error.message}
+ {details.error.message}
void details.refetch()}>Try again
)}
diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx
index afa59de41b3..a8e17e659c7 100644
--- a/apps/sim/ee/audit-logs/components/audit-logs.tsx
+++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx
@@ -581,7 +581,7 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
entries={allEntries.map(toActivityEntry)}
emptyState={
isLoading || isWorkspaceScopePending ? undefined : isWorkspaceScopeUnavailable ? (
-
+
Couldn't check that workspace. Refresh to try again.
) : isWorkspaceScopeUnresolved ? (
diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
index 97970396f1d..5d1d8641673 100644
--- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx
@@ -82,7 +82,7 @@ export function CustomBlocks() {
if (entitlementError && canManage === undefined) {
return (
-
+
{getErrorMessage(entitlementError, 'Failed to load custom block access')}
)
diff --git a/apps/sim/ee/data-drains/components/data-drains-settings.tsx b/apps/sim/ee/data-drains/components/data-drains-settings.tsx
index 9a0235acdf3..5880c11931d 100644
--- a/apps/sim/ee/data-drains/components/data-drains-settings.tsx
+++ b/apps/sim/ee/data-drains/components/data-drains-settings.tsx
@@ -108,7 +108,7 @@ export function DataDrainsSettings({ organizationId }: DataDrainsSettingsProps)
}}
>
{error ? (
-
+
{getErrorMessage(error, "Couldn't load data drains")}
) : isPending ? null : drains && drains.length > 0 ? (
diff --git a/apps/sim/ee/organization-usage/components/activity-panel.tsx b/apps/sim/ee/organization-usage/components/activity-panel.tsx
index d3aff2fca49..6b7cef36204 100644
--- a/apps/sim/ee/organization-usage/components/activity-panel.tsx
+++ b/apps/sim/ee/organization-usage/components/activity-panel.tsx
@@ -115,7 +115,7 @@ export function ActivityPanel({ organizationId }: ActivityPanelProps) {
)}
{breakdown.isError ? (
-
+
Couldn't load the breakdown.{' '}
void breakdown.refetch()}>Retry
diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx
index fabea9ebcff..ac8087b0888 100644
--- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx
+++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx
@@ -212,11 +212,7 @@ export function UsageConsumers({
onExpandOther,
}: UsageConsumersProps) {
if (isError) {
- return (
-
- Couldn't load this view.
-
- )
+ return Couldn't load this view.
}
if (isLoading || !breakdown) {
return Loading…
diff --git a/apps/sim/ee/organization-usage/components/usage-events-view.tsx b/apps/sim/ee/organization-usage/components/usage-events-view.tsx
index f15f9efae95..2357f83e2d6 100644
--- a/apps/sim/ee/organization-usage/components/usage-events-view.tsx
+++ b/apps/sim/ee/organization-usage/components/usage-events-view.tsx
@@ -85,9 +85,7 @@ export function UsageEventsView({ organizationId, backHref }: UsageEventsViewPro
{isLoading ? (
Loading usage…
) : isError ? (
-
- Couldn't load usage events.
-
+ Couldn't load usage events.
) : events.length === 0 ? (
No usage in this period.
) : (
diff --git a/apps/sim/ee/session-policy/components/session-policy-settings.tsx b/apps/sim/ee/session-policy/components/session-policy-settings.tsx
index 36272b10524..14c7579b558 100644
--- a/apps/sim/ee/session-policy/components/session-policy-settings.tsx
+++ b/apps/sim/ee/session-policy/components/session-policy-settings.tsx
@@ -236,7 +236,7 @@ export function SessionPolicySettings({ organizationId }: SessionPolicySettingsP
if (!data) {
return (
-
+
{getErrorMessage(error, 'Failed to load session policy')}
diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx
index 6efb6418bec..0073a562ca6 100644
--- a/apps/sim/ee/sso/components/sso-settings.test.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.test.tsx
@@ -12,14 +12,14 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
const {
mockUseConfigureSSO,
mockUseDeleteSSOProvider,
- mockUseOrganizationBilling,
+ mockUseOrganizationPlanSeats,
mockUseSetPrimarySSOProvider,
mockUseSSOProviders,
} = vi.hoisted(() => ({
mockUseConfigureSSO: vi.fn(),
mockUseDeleteSSOProvider: vi.fn(),
mockUseSetPrimarySSOProvider: vi.fn(),
- mockUseOrganizationBilling: vi.fn(),
+ mockUseOrganizationPlanSeats: vi.fn(),
mockUseSSOProviders: vi.fn(),
}))
@@ -258,8 +258,8 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-resource-row
),
}))
-vi.mock('@/hooks/queries/organization', () => ({
- useOrganizationBilling: mockUseOrganizationBilling,
+vi.mock('@/hooks/queries/organization-plan-seats', () => ({
+ useOrganizationPlanSeats: mockUseOrganizationPlanSeats,
}))
import { SSO } from '@/ee/sso/components/sso-settings'
@@ -333,8 +333,8 @@ beforeEach(() => {
document.body.appendChild(container)
root = createRoot(container)
mockUseSession.mockReturnValue({ data: { user: { id: 'user-1' } } })
- mockUseOrganizationBilling.mockReturnValue({
- data: { data: { subscriptionPlan: 'enterprise' } },
+ mockUseOrganizationPlanSeats.mockReturnValue({
+ data: { data: { subscriptionPlan: 'enterprise', hasEnterprisePlan: true } },
error: null,
isFetching: false,
isLoading: false,
@@ -374,6 +374,18 @@ afterEach(() => {
* their secret or saves the literal string "[REDACTED]" as one.
*/
describe('SSO client secret preservation', () => {
+ it('withholds provider editing when an Enterprise subscription is not usable', () => {
+ mockUseOrganizationPlanSeats.mockReturnValue({
+ data: { data: { subscriptionPlan: 'enterprise', hasEnterprisePlan: false } },
+ isLoading: false,
+ error: null,
+ })
+ renderSso('org-a')
+ startEditing()
+ expect(container.querySelector('#sso-client-secret')).toBeNull()
+ expect(findButton('Update')).toBeUndefined()
+ })
+
function secretInput() {
return container.querySelector('#sso-client-secret')
}
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
index 51c300fba1c..c6d0e44fbff 100644
--- a/apps/sim/ee/sso/components/sso-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -4,7 +4,6 @@ import { useState } from 'react'
import { ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryStates } from 'nuqs'
-import { isEnterprise } from '@/lib/billing/plan-helpers'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import {
SettingsEmptyState,
@@ -18,7 +17,7 @@ import { SsoProviderSettings } from '@/ee/sso/components/sso-provider-settings'
import { VerifiedDomainsSection } from '@/ee/sso/components/verified-domains-section'
import { useDeleteSSOProvider, useSetPrimarySSOProvider, useSSOProviders } from '@/ee/sso/hooks/sso'
import { ssoSettingsParsers, ssoSettingsUrlKeys } from '@/ee/sso/search-params'
-import { useOrganizationBilling } from '@/hooks/queries/organization'
+import { useOrganizationPlanSeats } from '@/hooks/queries/organization-plan-seats'
const SETTINGS_TABS = [
{ value: 'sign-in', label: 'Sign-in' },
@@ -44,7 +43,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
const [{ tab: requestedTab, provider: requestedProvider, createProvider }, setParams] =
useQueryStates(ssoSettingsParsers, ssoSettingsUrlKeys)
const { billingEnabled, features } = useDeploymentShape()
- const billing = useOrganizationBilling(organizationId)
+ const billing = useOrganizationPlanSeats(organizationId, { enabled: billingEnabled })
const providers = useSSOProviders({ organizationId })
const provisioningAvailable = features.scim
const tab = requestedTab === 'provisioning' && !provisioningAvailable ? 'sign-in' : requestedTab
@@ -118,14 +117,14 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
return (
void billing.refetch()}
/>
)
}
- if (billingEnabled && !isEnterprise(billing.data?.data?.subscriptionPlan)) {
+ if (billingEnabled && !billing.data?.data.hasEnterprisePlan) {
return (
Single Sign-On is available on Enterprise plans only.
)
@@ -133,6 +132,15 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
return (
+ {billing.error != null && (
+
void billing.refetch()}
+ variant='inline'
+ />
+ )}
entry.value !== 'provisioning' || provisioningAvailable
diff --git a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
index 38c8d1945c0..7b97e302832 100644
--- a/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
+++ b/apps/sim/ee/whitelabeling/components/whitelabeling-settings.tsx
@@ -455,7 +455,7 @@ export function WhitelabelingSettings({ organizationId: orgId }: WhitelabelingSe
if (!whitelabel) {
return (
-
+
{getErrorMessage(settingsError, 'Failed to load whitelabeling settings')}
)
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx
index 9accdc55bc7..72a2ea1b94f 100644
--- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx
+++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx
@@ -880,7 +880,7 @@ export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProp
{/* Surface a failed/pending fetch so the page never renders blank below the direction. */}
{detailsError ? (
- {detailsError}
+ {detailsError}
) : !controller.hasDiff ? (
Loading sync details…
diff --git a/apps/sim/ee/workspace-forking/components/forks.tsx b/apps/sim/ee/workspace-forking/components/forks.tsx
index a14257b52d8..f310f55fac2 100644
--- a/apps/sim/ee/workspace-forking/components/forks.tsx
+++ b/apps/sim/ee/workspace-forking/components/forks.tsx
@@ -305,7 +305,7 @@ export function Forks() {
>
{lineage.isError ? (
-
+
{getErrorMessage(lineage.error, 'Failed to load forks')}
diff --git a/apps/sim/hooks/queries/organization-billing-summary.ts b/apps/sim/hooks/queries/organization-billing-summary.ts
index 61cbe9c0e13..92cd68e15de 100644
--- a/apps/sim/hooks/queries/organization-billing-summary.ts
+++ b/apps/sim/hooks/queries/organization-billing-summary.ts
@@ -1,20 +1,11 @@
import { queryOptions, useQuery } from '@tanstack/react-query'
-import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization'
import { organizationKeys } from '@/hooks/queries/utils/organization-keys'
+import { shouldRetrySettingsRead } from '@/hooks/queries/utils/settings-read-retry'
export const ORGANIZATION_BILLING_SUMMARY_STALE_TIME = 30 * 1000
-export function shouldRetryOrganizationBillingSummary(
- failureCount: number,
- error: unknown
-): boolean {
- if (failureCount >= 1) return false
- if (!isApiClientError(error)) return true
- return error.status === 408 || error.status === 429 || error.status >= 500
-}
-
export function organizationBillingSummaryOptions(orgId: string) {
return queryOptions({
queryKey: organizationKeys.billingSummary(orgId),
@@ -23,7 +14,7 @@ export function organizationBillingSummaryOptions(orgId: string) {
params: { id: orgId },
signal,
}),
- retry: shouldRetryOrganizationBillingSummary,
+ retry: shouldRetrySettingsRead,
retryOnMount: true,
staleTime: ORGANIZATION_BILLING_SUMMARY_STALE_TIME,
})
diff --git a/apps/sim/hooks/queries/organization-plan-seats.ts b/apps/sim/hooks/queries/organization-plan-seats.ts
new file mode 100644
index 00000000000..e3cb60e4b81
--- /dev/null
+++ b/apps/sim/hooks/queries/organization-plan-seats.ts
@@ -0,0 +1,30 @@
+import { queryOptions, useQuery } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
+import {
+ getOrganizationPlanSeatsContract,
+ type OrganizationPlanSeatsResponse,
+} from '@/lib/api/contracts/organization'
+import { organizationKeys } from '@/hooks/queries/utils/organization-keys'
+import { shouldRetrySettingsRead } from '@/hooks/queries/utils/settings-read-retry'
+
+const ORGANIZATION_PLAN_SEATS_STALE_TIME = 30 * 1000
+
+/** Shared cache options for plan access and seat reservations without usage analytics. */
+export function organizationPlanSeatsOptions(organizationId: string) {
+ return queryOptions({
+ queryKey: organizationKeys.planSeats(organizationId),
+ queryFn: ({ signal }): Promise =>
+ requestJson(getOrganizationPlanSeatsContract, { params: { id: organizationId }, signal }),
+ staleTime: ORGANIZATION_PLAN_SEATS_STALE_TIME,
+ retry: shouldRetrySettingsRead,
+ retryOnMount: true,
+ })
+}
+
+/** Reads plan access and seat reservations for settings and invitation flows. */
+export function useOrganizationPlanSeats(organizationId: string, options?: { enabled?: boolean }) {
+ return useQuery({
+ ...organizationPlanSeatsOptions(organizationId),
+ enabled: Boolean(organizationId) && (options?.enabled ?? true),
+ })
+}
diff --git a/apps/sim/hooks/queries/organization.test.tsx b/apps/sim/hooks/queries/organization.test.tsx
index 84a8e618e38..0a1a52d2b29 100644
--- a/apps/sim/hooks/queries/organization.test.tsx
+++ b/apps/sim/hooks/queries/organization.test.tsx
@@ -24,20 +24,18 @@ vi.mock('@/lib/api/client/request', () => apiClientRequestMock)
vi.mock('@/lib/auth/auth-client', () => authClientMock)
import {
+ getOrganizationPlanSeatsContract,
getOrganizationRosterContract,
+ type OrganizationPlanSeatsResponse,
type OrganizationRoster,
} from '@/lib/api/contracts/organization'
-import {
- getOrganizationBillingContract,
- type OrganizationBillingApiResponse,
-} from '@/lib/api/contracts/subscription'
import {
organizationKeys,
useOrganization,
- useOrganizationBilling,
useOrganizationRoster,
} from '@/hooks/queries/organization'
-import { shouldRetryOrganizationBillingSummary } from '@/hooks/queries/organization-billing-summary'
+import { useOrganizationPlanSeats } from '@/hooks/queries/organization-plan-seats'
+import { shouldRetrySettingsRead } from '@/hooks/queries/utils/settings-read-retry'
const { getFullOrganization: mockGetFullOrganization } = authClientMockFns.mockClient.organization
@@ -69,12 +67,18 @@ const ROSTER_A: { success: true; data: OrganizationRoster } = {
},
}
-const BILLING_A = {
+const BILLING_A: OrganizationPlanSeatsResponse = {
+ success: true,
data: {
organizationId: 'org-a',
subscriptionPlan: 'enterprise',
+ subscriptionStatus: 'active',
+ totalSeats: 7,
+ usedSeats: 2,
+ membersTotal: 2,
+ hasEnterprisePlan: true,
},
-} as OrganizationBillingApiResponse
+}
let container: HTMLDivElement
let root: Root
@@ -83,7 +87,7 @@ let queryClient: QueryClient
function OrganizationProbe({ organizationId }: { organizationId: string }) {
const organization = useOrganization(organizationId)
const roster = useOrganizationRoster(organizationId)
- const billing = useOrganizationBilling(organizationId)
+ const billing = useOrganizationPlanSeats(organizationId)
const canManage = Boolean(organization.data && roster.data && billing.data)
return (
@@ -151,7 +155,7 @@ describe('organization identity transitions', () => {
it('clears organization detail, roster, billing, and actions while the next org loads', async () => {
const organizationB = createDeferred<{ data: typeof ORGANIZATION_A }>()
const rosterB = createDeferred()
- const billingB = createDeferred()
+ const billingB = createDeferred()
mockGetFullOrganization.mockImplementation(
({ query }: { query: { organizationId: string } }) =>
@@ -170,8 +174,8 @@ describe('organization identity transitions', () => {
if (contract === getOrganizationRosterContract) {
return input.params?.id === 'org-a' ? Promise.resolve(ROSTER_A) : rosterB.promise
}
- if (contract === getOrganizationBillingContract) {
- return input.query?.id === 'org-a' ? Promise.resolve(BILLING_A) : billingB.promise
+ if (contract === getOrganizationPlanSeatsContract) {
+ return input.params?.id === 'org-a' ? Promise.resolve(BILLING_A) : billingB.promise
}
throw new Error('Unexpected contract')
}
@@ -199,7 +203,7 @@ describe('organization identity transitions', () => {
it('retries one transient billing-summary failure without retrying authorization errors', () => {
const serverError = new ApiClientError({
- status: 503,
+ status: 500,
message: 'Unavailable',
body: null,
})
@@ -209,9 +213,15 @@ describe('organization identity transitions', () => {
body: null,
})
- expect(shouldRetryOrganizationBillingSummary(0, serverError)).toBe(true)
- expect(shouldRetryOrganizationBillingSummary(1, serverError)).toBe(false)
- expect(shouldRetryOrganizationBillingSummary(0, forbiddenError)).toBe(false)
- expect(shouldRetryOrganizationBillingSummary(0, new TypeError('Network error'))).toBe(true)
+ expect(shouldRetrySettingsRead(0, serverError)).toBe(true)
+ expect(
+ shouldRetrySettingsRead(
+ 0,
+ new ApiClientError({ status: 503, message: 'Unavailable', body: null })
+ )
+ ).toBe(false)
+ expect(shouldRetrySettingsRead(1, serverError)).toBe(false)
+ expect(shouldRetrySettingsRead(0, forbiddenError)).toBe(false)
+ expect(shouldRetrySettingsRead(0, new TypeError('Network error'))).toBe(true)
})
})
diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts
index 47b7f611adb..8a675005c59 100644
--- a/apps/sim/hooks/queries/organization.ts
+++ b/apps/sim/hooks/queries/organization.ts
@@ -1,11 +1,4 @@
-import { isRecordLike, toRecord } from '@sim/utils/object'
-import {
- queryOptions,
- type UseQueryResult,
- useMutation,
- useQuery,
- useQueryClient,
-} from '@tanstack/react-query'
+import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
@@ -33,10 +26,6 @@ import {
updateOrganizationMemberUsageLimitContract,
updateOrganizationUsageLimitContract,
} from '@/lib/api/contracts/organization'
-import {
- getOrganizationBillingContract,
- type OrganizationBillingApiResponse,
-} from '@/lib/api/contracts/subscription'
import { client } from '@/lib/auth/auth-client'
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
import { organizationKeys } from '@/hooks/queries/utils/organization-keys'
@@ -48,7 +37,6 @@ const invitationListsKey = ['invitations', 'list'] as const
export const ORGANIZATION_ROSTER_STALE_TIME = 30 * 1000
export const ORGANIZATION_DETAIL_STALE_TIME = 30 * 1000
-export const ORGANIZATION_BILLING_STALE_TIME = 30 * 1000
export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000
/**
* Zero: removal impact is a consent disclosure, so every dialog open must
@@ -57,17 +45,6 @@ export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000
*/
export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0
-type OrganizationBillingQueryResult = UseQueryResult
-
-function readNumber(value: unknown): number | undefined {
- if (typeof value === 'number') return value
- if (typeof value === 'string') {
- const parsed = Number.parseFloat(value)
- return Number.isFinite(parsed) ? parsed : undefined
- }
- return undefined
-}
-
export { organizationKeys }
export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess }
@@ -173,45 +150,6 @@ export function useOrganization(orgId: string) {
})
}
-/**
- * Fetch organization billing data
- */
-async function fetchOrganizationBilling(
- orgId: string,
- signal?: AbortSignal
-): Promise {
- try {
- return await requestJson(getOrganizationBillingContract, {
- query: { context: 'organization', id: orgId },
- signal,
- })
- } catch (error) {
- if (error instanceof ApiClientError && error.status === 404) {
- return null
- }
- throw error
- }
-}
-
-export function organizationBillingQueryOptions(orgId: string) {
- return queryOptions({
- queryKey: organizationKeys.billing(orgId),
- queryFn: ({ signal }) => fetchOrganizationBilling(orgId, signal),
- retry: false,
- staleTime: ORGANIZATION_BILLING_STALE_TIME,
- })
-}
-
-export function useOrganizationBilling(
- orgId: string,
- options?: { enabled?: boolean }
-): OrganizationBillingQueryResult {
- return useQuery({
- ...organizationBillingQueryOptions(orgId),
- enabled: !!orgId && (options?.enabled ?? true),
- })
-}
-
/**
* Update organization usage limit mutation with optimistic updates
*/
@@ -233,45 +171,9 @@ export function useUpdateOrganizationUsageLimit() {
await queryClient.cancelQueries({
queryKey: organizationKeys.billing(organizationId),
})
- await queryClient.cancelQueries({
- queryKey: organizationKeys.subscription(organizationId),
- })
-
- const previousBillingData = queryClient.getQueryData(organizationKeys.billing(organizationId))
const previousBillingSummary = queryClient.getQueryData(
organizationKeys.billingSummary(organizationId)
)
- const previousSubscriptionData = queryClient.getQueryData(
- organizationKeys.subscription(organizationId)
- )
-
- queryClient.setQueryData(
- organizationKeys.billing(organizationId),
- (old: unknown) => {
- if (!isRecordLike(old) || !isRecordLike(old.data)) return old
- const usage = toRecord(old.data.usage)
- const currentUsage =
- readNumber(old.data.currentUsage) ??
- readNumber(usage.current) ??
- readNumber(old.data.totalCurrentUsage) ??
- 0
- const newPercentUsed = limit > 0 ? (currentUsage / limit) * 100 : 0
-
- return {
- ...old,
- data: {
- ...old.data,
- totalUsageLimit: limit,
- usage: {
- ...usage,
- limit,
- percentUsed: newPercentUsed,
- },
- percentUsed: newPercentUsed,
- },
- }
- }
- )
queryClient.setQueryData<{
success: true
@@ -286,25 +188,11 @@ export function useUpdateOrganizationUsageLimit() {
)
return {
- previousBillingData,
previousBillingSummary,
- previousSubscriptionData,
organizationId,
}
},
onError: (_err, _variables, context) => {
- if (context?.previousBillingData && context?.organizationId) {
- queryClient.setQueryData(
- organizationKeys.billing(context.organizationId),
- context.previousBillingData
- )
- }
- if (context?.previousSubscriptionData && context?.organizationId) {
- queryClient.setQueryData(
- organizationKeys.subscription(context.organizationId),
- context.previousSubscriptionData
- )
- }
if (context?.previousBillingSummary && context?.organizationId) {
queryClient.setQueryData(
organizationKeys.billingSummary(context.organizationId),
diff --git a/apps/sim/hooks/queries/utils/organization-keys.ts b/apps/sim/hooks/queries/utils/organization-keys.ts
index 2873410cf8b..11d90164c84 100644
--- a/apps/sim/hooks/queries/utils/organization-keys.ts
+++ b/apps/sim/hooks/queries/utils/organization-keys.ts
@@ -5,6 +5,7 @@ export const organizationKeys = {
detail: (id: string) => [...organizationKeys.details(), id] as const,
subscription: (id: string) => [...organizationKeys.detail(id), 'subscription'] as const,
billing: (id: string) => [...organizationKeys.detail(id), 'billing'] as const,
+ planSeats: (id: string) => [...organizationKeys.billing(id), 'plan-seats'] as const,
billingSummary: (id: string) => [...organizationKeys.billing(id), 'summary'] as const,
members: (id: string) => [...organizationKeys.detail(id), 'members'] as const,
memberUsage: (id: string) => [...organizationKeys.detail(id), 'member-usage'] as const,
diff --git a/apps/sim/hooks/queries/utils/settings-read-retry.ts b/apps/sim/hooks/queries/utils/settings-read-retry.ts
new file mode 100644
index 00000000000..d056d50872e
--- /dev/null
+++ b/apps/sim/hooks/queries/utils/settings-read-retry.ts
@@ -0,0 +1,9 @@
+import { isApiClientError } from '@/lib/api/client/errors'
+
+/** Leaves service-unavailable recovery to explicit retries and remounts. */
+export function shouldRetrySettingsRead(failureCount: number, error: unknown): boolean {
+ if (failureCount >= 1) return false
+ if (!isApiClientError(error)) return true
+ if (error.status === 503) return false
+ return error.status === 408 || error.status === 429 || error.status >= 500
+}
diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts
index db3c051aa77..db305b83b7b 100644
--- a/apps/sim/lib/api/contracts/organization.ts
+++ b/apps/sim/lib/api/contracts/organization.ts
@@ -718,6 +718,33 @@ export const createOrganizationContract = defineRouteContract({
},
})
+const organizationPlanSeatsResponseSchema = z.object({
+ success: z.literal(true),
+ data: z.object({
+ organizationId: z.string().min(1),
+ subscriptionPlan: z.string().min(1).nullable(),
+ subscriptionStatus: z.string().nullable(),
+ totalSeats: z.number().int().nonnegative(),
+ usedSeats: z.number().int().nonnegative(),
+ membersTotal: z.number().int().nonnegative(),
+ hasEnterprisePlan: z.boolean(),
+ }),
+})
+
+/** Primary-backed plan access and canonical member/invitation seat reservations. */
+export type OrganizationPlanSeatsResponse = z.output
+
+/** Lightweight settings read that excludes usage-ledger aggregates. */
+export const getOrganizationPlanSeatsContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/organizations/[id]/billing-plan',
+ params: organizationParamsSchema,
+ response: {
+ mode: 'json',
+ schema: organizationPlanSeatsResponseSchema,
+ },
+})
+
export const organizationBillingSummarySchema = z.object({
organizationId: z.string().min(1),
subscriptionState: z.enum(['active', 'free', 'lapsed']),
diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts
index b78415dc1ee..e0932a3d8ba 100644
--- a/apps/sim/lib/api/contracts/subscription.ts
+++ b/apps/sim/lib/api/contracts/subscription.ts
@@ -247,19 +247,6 @@ export const getUserBillingContract = defineRouteContract({
},
})
-export const getOrganizationBillingContract = defineRouteContract({
- method: 'GET',
- path: '/api/billing',
- query: billingQuerySchema.extend({
- context: z.literal('organization'),
- id: z.string().min(1),
- }),
- response: {
- mode: 'json',
- schema: organizationBillingApiResponseSchema,
- },
-})
-
export const getUsageLimitContract = defineRouteContract({
method: 'GET',
path: '/api/usage',
@@ -394,7 +381,6 @@ export const billingUpdateCostContract = defineRouteContract({
export type BillingUsageData = z.infer
export type SubscriptionBillingData = z.infer
export type SubscriptionApiResponse = z.infer
-export type OrganizationBillingApiResponse = z.infer
export type UsageLimitApiResponse = z.infer
export type InvoiceItem = z.infer
export type InvoicesApiResponse = z.infer
diff --git a/apps/sim/lib/api/server/routes/billing-read.ts b/apps/sim/lib/api/server/routes/billing-read.ts
new file mode 100644
index 00000000000..97d750381f3
--- /dev/null
+++ b/apps/sim/lib/api/server/routes/billing-read.ts
@@ -0,0 +1,27 @@
+import { getPostgresCancellationReason } from '@sim/utils/errors'
+import {
+ extendInternalErrorPolicy,
+ internalErrorResponse,
+ internalOrchestrationErrorPolicy,
+} from '@/lib/api/server/routes/internal-json-route'
+import { DatabaseReadDeadlineError, isTransientDatabaseReadError } from '@/lib/db/read-retry'
+
+/** Presents known temporary read failures as retryable 503s without masking permanent errors. */
+export const internalBillingReadErrorPolicy = extendInternalErrorPolicy(
+ internalOrchestrationErrorPolicy,
+ (error) => {
+ const cancellationReason = getPostgresCancellationReason(error)
+ if (
+ !(error instanceof DatabaseReadDeadlineError) &&
+ !isTransientDatabaseReadError(error) &&
+ cancellationReason !== 'statement_timeout' &&
+ cancellationReason !== 'transaction_timeout'
+ )
+ return null
+ return internalErrorResponse(
+ 503,
+ { error: 'Billing information is temporarily unavailable. Please try again.' },
+ { 'Retry-After': '5' }
+ )
+ }
+)
diff --git a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts b/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts
deleted file mode 100644
index b4ec701d800..00000000000
--- a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
-import { billingCoreMock, billingCoreMockFns } from '@sim/testing/mocks/billing-core.mock'
-import {
- billingPlanHelpersMock,
- billingPlanHelpersMockFns,
-} from '@sim/testing/mocks/billing-plan-helpers.mock'
-import { billingSubscriptionMock } from '@sim/testing/mocks/billing-subscription.mock'
-import {
- billingSubscriptionUtilsMock,
- billingSubscriptionUtilsMockFns,
-} from '@sim/testing/mocks/billing-subscription-utils.mock'
-import {
- billingUsageLogMock,
- billingUsageLogMockFns,
-} from '@sim/testing/mocks/billing-usage-log.mock'
-import { schemaMock } from '@sim/testing/mocks/schema.mock'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const hoisted = vi.hoisted(() => {
- const primaryRows = new Map()
- const selectedPrimaryTables: object[] = []
-
- const primaryDb = {
- select: vi.fn(() => {
- let selectedTable: object
- const query = {
- from: vi.fn((table: object) => {
- selectedTable = table
- selectedPrimaryTables.push(table)
- return query
- }),
- where: vi.fn(() => query),
- orderBy: vi.fn(() => query),
- limit: vi.fn(async () => primaryRows.get(selectedTable) ?? []),
- }
- return query
- }),
- }
- const replicaDb = {
- select: vi.fn(() => {
- throw new Error('Canonical billing state must not be read from the replica')
- }),
- }
-
- return {
- primaryRows,
- selectedPrimaryTables,
- primaryDb,
- replicaDb,
- getOrganizationBillingBlockState: vi.fn(),
- getUpgradeWorkspaceId: vi.fn(),
- resolveSubscriptionUsagePeriodOrDefault: vi.fn(),
- computeWeeklyRefreshConsumed: vi.fn(),
- }
-})
-
-vi.mock('@sim/db', () => ({
- db: hoisted.primaryDb,
- dbReplica: hoisted.replicaDb,
-}))
-
-vi.mock('@/lib/billing/core/billing', () => billingCoreMock)
-
-vi.mock('@/lib/billing/core/payer-context', () => ({
- getOrganizationBillingBlockState: hoisted.getOrganizationBillingBlockState,
- getUpgradeWorkspaceId: hoisted.getUpgradeWorkspaceId,
-}))
-
-vi.mock('@/lib/billing/core/reporting-period', () => ({
- resolveSubscriptionUsagePeriodOrDefault: hoisted.resolveSubscriptionUsagePeriodOrDefault,
-}))
-
-vi.mock('@/lib/billing/core/subscription', () => billingSubscriptionMock)
-
-vi.mock('@/lib/billing/core/usage-log', () => billingUsageLogMock)
-
-vi.mock('@/lib/billing/credits/weekly-refresh', () => ({
- computeWeeklyRefreshConsumed: hoisted.computeWeeklyRefreshConsumed,
-}))
-
-vi.mock('@/lib/billing/plan-helpers', () => billingPlanHelpersMock)
-
-vi.mock('@/lib/billing/subscriptions/utils', () => billingSubscriptionUtilsMock)
-
-vi.mock('@/lib/billing/utils/decimal', () => ({
- toDecimal: vi.fn((value: string | number | null | undefined) => Number(value ?? 0)),
- toNumber: vi.fn((value: number) => value),
-}))
-
-import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
-
-const mocks = {
- ...hoisted,
- getOrganizationSubscription: billingCoreMockFns.mockGetOrganizationSubscription,
- getBillingPeriodUsageCost: billingUsageLogMockFns.mockGetBillingPeriodUsageCost,
-}
-billingCoreMockFns.mockGetPlanPricing.mockReturnValue({ basePrice: 20 } as never)
-billingPlanHelpersMockFns.mockGetPlanWeeklyRefreshDollars.mockReturnValue(10)
-billingPlanHelpersMockFns.mockIsEnterprise.mockReturnValue(false)
-billingPlanHelpersMockFns.mockIsPaid.mockImplementation(
- (plan: string | null | undefined) => plan !== 'free'
-)
-billingSubscriptionUtilsMockFns.mockGetEffectiveSeats.mockReturnValue(2)
-
-const tables = {
- member: schemaMock.member,
- organization: schemaMock.organization,
- subscription: schemaMock.subscription,
-}
-const session = createSessionPrincipal()
-
-describe('organization billing summary query routing', () => {
- beforeEach(() => {
- mocks.primaryRows.clear()
- mocks.selectedPrimaryTables.length = 0
-
- const periodStart = new Date('2026-08-01T00:00:00.000Z')
- const periodEnd = new Date('2026-09-01T00:00:00.000Z')
- const subscription = {
- id: 'sub-1',
- referenceId: 'org-1',
- plan: 'team',
- status: 'active',
- seats: 2,
- periodStart,
- periodEnd,
- cancelAtPeriodEnd: false,
- }
-
- mocks.primaryRows.set(tables.member, [{ role: 'owner' }])
- mocks.primaryRows.set(tables.organization, [
- { id: 'org-1', orgUsageLimit: null, creditBalance: '3' },
- ])
- mocks.primaryRows.set(tables.subscription, [subscription])
- mocks.getOrganizationSubscription.mockResolvedValue(subscription)
- mocks.resolveSubscriptionUsagePeriodOrDefault.mockReturnValue({
- start: periodStart,
- end: periodEnd,
- })
- mocks.getOrganizationBillingBlockState.mockResolvedValue({
- billingBlocked: false,
- billingBlockedReason: null,
- blockedByOrgOwner: false,
- })
- mocks.getUpgradeWorkspaceId.mockResolvedValue('workspace-1')
- mocks.getBillingPeriodUsageCost.mockResolvedValue(25)
- mocks.computeWeeklyRefreshConsumed.mockResolvedValue(5)
- })
-
- it('uses primary state for payer decisions and the replica only for usage aggregates', async () => {
- await expect(
- getOrganizationBillingSummary.execute({
- principal: session,
- input: { organizationId: 'org-1' },
- })
- ).resolves.toMatchObject({
- organizationId: 'org-1',
- subscriptionPlan: 'team',
- totalCurrentUsage: 20,
- upgradeWorkspaceId: 'workspace-1',
- })
-
- expect(mocks.selectedPrimaryTables).toEqual([
- tables.member,
- tables.organization,
- tables.subscription,
- ])
- expect(mocks.replicaDb.select).not.toHaveBeenCalled()
- expect(mocks.getOrganizationSubscription).toHaveBeenCalledWith('org-1', {
- executor: mocks.primaryDb,
- onError: 'throw',
- })
- expect(mocks.getOrganizationBillingBlockState).toHaveBeenCalledWith(
- 'org-1',
- 'user-1',
- mocks.primaryDb
- )
- expect(mocks.getUpgradeWorkspaceId).toHaveBeenCalledWith(
- { type: 'organization', id: 'org-1' },
- mocks.primaryDb
- )
- expect(mocks.getBillingPeriodUsageCost).toHaveBeenCalledWith(
- { type: 'organization', id: 'org-1' },
- expect.any(Object),
- undefined,
- mocks.replicaDb
- )
- expect(mocks.computeWeeklyRefreshConsumed).toHaveBeenCalledWith(
- expect.objectContaining({
- billingEntity: { type: 'organization', id: 'org-1' },
- }),
- mocks.replicaDb
- )
- })
-})
diff --git a/apps/sim/lib/billing/application/organization-billing-summary/operations.ts b/apps/sim/lib/billing/application/organization-billing-summary/operations.ts
deleted file mode 100644
index 2ae6f8a8f69..00000000000
--- a/apps/sim/lib/billing/application/organization-billing-summary/operations.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { Principal } from '@sim/auth/principal'
-import type { ApplicationOperation } from '@/lib/core/application'
-import { assertOperationCapability } from '@/lib/core/application/operation'
-
-export type OrganizationBillingSummaryPrincipal = Extract<
- Principal,
- { kind: 'session' | 'organization_delegated' }
->
-
-export interface OrganizationBillingSummaryOperation
- extends ApplicationOperation {
- readonly organizationRoles: readonly ['admin', 'owner']
- readonly workspaceApiKey: 'deny'
- readonly principalKinds: readonly ['session', 'organization_delegated']
-}
-
-function defineOrganizationBillingSummaryOperation(
- operation: OrganizationBillingSummaryOperation
-): OrganizationBillingSummaryOperation {
- assertOperationCapability(operation)
- Object.freeze(operation.organizationRoles)
- Object.freeze(operation.principalKinds)
- return Object.freeze(operation)
-}
-
-export const organizationBillingSummaryOperations = {
- // permission-group-exempt: an organization-admin surface — admins and owners sit above every group, and no group key names organization billing
- read: defineOrganizationBillingSummaryOperation({
- id: 'organization_billing.summary.read',
- organizationRoles: ['admin', 'owner'],
- workspaceApiKey: 'deny',
- principalKinds: ['session', 'organization_delegated'],
- capability: 'none',
- }),
-} as const
diff --git a/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.test.ts b/apps/sim/lib/billing/application/organization-billing/authorized-organization-billing-use-case.test.ts
similarity index 82%
rename from apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.test.ts
rename to apps/sim/lib/billing/application/organization-billing/authorized-organization-billing-use-case.test.ts
index b03dcb23ff3..34b98b904e2 100644
--- a/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.test.ts
+++ b/apps/sim/lib/billing/application/organization-billing/authorized-organization-billing-use-case.test.ts
@@ -5,8 +5,8 @@ import {
} from '@sim/testing/factories/principal.factory'
import { dbChainMockFns } from '@sim/testing/mocks/database.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { defineAuthorizedOrganizationBillingSummaryUseCase } from '@/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case'
-import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations'
+import { defineAuthorizedOrganizationBillingUseCase } from '@/lib/billing/application/organization-billing/authorized-organization-billing-use-case'
+import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'
import { ForbiddenOperationError } from '@/lib/core/application'
const mocks = {
@@ -17,8 +17,8 @@ const mocks = {
const session = createSessionPrincipal()
const personalKey = createPersonalApiKeyPrincipal()
-const useCase = defineAuthorizedOrganizationBillingSummaryUseCase({
- operation: organizationBillingSummaryOperations.read,
+const useCase = defineAuthorizedOrganizationBillingUseCase({
+ operation: organizationBillingOperations.read,
organizationId: (input: { organizationId: string }) => input.organizationId,
execute: mocks.execute,
})
diff --git a/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts b/apps/sim/lib/billing/application/organization-billing/authorized-organization-billing-use-case.ts
similarity index 70%
rename from apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts
rename to apps/sim/lib/billing/application/organization-billing/authorized-organization-billing-use-case.ts
index 9eb1530a1db..7d937c99775 100644
--- a/apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts
+++ b/apps/sim/lib/billing/application/organization-billing/authorized-organization-billing-use-case.ts
@@ -3,36 +3,32 @@ import { db } from '@sim/db'
import { member } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type {
- OrganizationBillingSummaryOperation,
- OrganizationBillingSummaryPrincipal,
-} from '@/lib/billing/application/organization-billing-summary/operations'
+ OrganizationBillingOperation,
+ OrganizationBillingPrincipal,
+} from '@/lib/billing/application/organization-billing/operations'
import { organizationBillingSettingsActor } from '@/lib/billing/application/organization-settings-actor'
import { ForbiddenOperationError, type OperationUseCase } from '@/lib/core/application'
-export interface AuthorizedOrganizationBillingSummaryContext {
+interface AuthorizedOrganizationBillingContext {
organizationId: string
actorUserId: string
userRole: 'admin' | 'owner'
}
-interface AuthorizedOrganizationBillingSummaryDefinition<
- O extends OrganizationBillingSummaryOperation,
- I,
- R,
-> {
+interface AuthorizedOrganizationBillingDefinition {
operation: O
organizationId(input: I): string
execute(args: {
- principal: OrganizationBillingSummaryPrincipal
+ principal: OrganizationBillingPrincipal
input: I
- context: AuthorizedOrganizationBillingSummaryContext
+ context: AuthorizedOrganizationBillingContext
}): Promise
}
function requireBillingSettingsPrincipal(
principal: Principal,
- operation: OrganizationBillingSummaryOperation
-): asserts principal is OrganizationBillingSummaryPrincipal {
+ operation: OrganizationBillingOperation
+): asserts principal is OrganizationBillingPrincipal {
if (!operation.principalKinds.some((kind) => kind === principal.kind)) {
throw new ForbiddenOperationError(
'PRINCIPAL_KIND_NOT_PERMITTED',
@@ -43,14 +39,14 @@ function requireBillingSettingsPrincipal(
/**
* Authorizes the organization payer read once and carries the canonical role into
- * presentation. Membership alone is insufficient because the summary includes the
- * organization's pooled spend, payment state, and configurable usage ceiling.
+ * presentation. Billing settings require current administrator authority, including
+ * plan and seat reads that do not load usage analytics.
*/
-export function defineAuthorizedOrganizationBillingSummaryUseCase<
- const O extends OrganizationBillingSummaryOperation,
+export function defineAuthorizedOrganizationBillingUseCase<
+ const O extends OrganizationBillingOperation,
I,
R,
->(definition: AuthorizedOrganizationBillingSummaryDefinition): OperationUseCase {
+>(definition: AuthorizedOrganizationBillingDefinition): OperationUseCase {
return {
operation: definition.operation,
async execute({ principal, input }) {
diff --git a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts b/apps/sim/lib/billing/application/organization-billing/get-organization-billing-summary.ts
similarity index 94%
rename from apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts
rename to apps/sim/lib/billing/application/organization-billing/get-organization-billing-summary.ts
index c3093fc6d20..e3c18d1e2b6 100644
--- a/apps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.ts
+++ b/apps/sim/lib/billing/application/organization-billing/get-organization-billing-summary.ts
@@ -1,8 +1,8 @@
import { db, dbReplica } from '@sim/db'
import { organization, subscription as subscriptionTable } from '@sim/db/schema'
import { desc, eq } from 'drizzle-orm'
-import { defineAuthorizedOrganizationBillingSummaryUseCase } from '@/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case'
-import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations'
+import { defineAuthorizedOrganizationBillingUseCase } from '@/lib/billing/application/organization-billing/authorized-organization-billing-use-case'
+import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
import {
getOrganizationBillingBlockState,
@@ -50,8 +50,8 @@ function roundCurrency(value: number): number {
* Member pages, invitation counts, member ledgers, and limit aggregates remain on
* their dedicated surfaces instead of delaying this navigation-critical response.
*/
-export const getOrganizationBillingSummary = defineAuthorizedOrganizationBillingSummaryUseCase({
- operation: organizationBillingSummaryOperations.read,
+export const getOrganizationBillingSummary = defineAuthorizedOrganizationBillingUseCase({
+ operation: organizationBillingOperations.read,
organizationId: (input: OrganizationBillingSummaryInput) => input.organizationId,
async execute({ context }): Promise {
const { organizationId, actorUserId, userRole } = context
diff --git a/apps/sim/lib/billing/application/organization-billing/get-organization-plan-seats.integration.ts b/apps/sim/lib/billing/application/organization-billing/get-organization-plan-seats.integration.ts
new file mode 100644
index 00000000000..b468868972f
--- /dev/null
+++ b/apps/sim/lib/billing/application/organization-billing/get-organization-plan-seats.integration.ts
@@ -0,0 +1,199 @@
+import { once } from 'node:events'
+import { createServer } from 'node:http'
+import type { db } from '@sim/db'
+import * as schema from '@sim/db/schema'
+import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
+import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock'
+import { generateId } from '@sim/utils/id'
+import { drizzle } from 'drizzle-orm/postgres-js'
+import { NextRequest } from 'next/server'
+import postgres from 'postgres'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { primary } = vi.hoisted(() => {
+ process.env.BILLING_ENABLED = 'true'
+ process.env.NEXT_PUBLIC_FORCE_HOSTED = 'true'
+ process.env.ACCESS_CONTROL_ENABLED = 'false'
+ return { primary: { select: vi.fn(), execute: vi.fn(), transaction: vi.fn() } }
+})
+vi.mock('@sim/db', () => ({ db: primary, dbReplica: {} }))
+vi.mock('@/lib/auth', () => authMock)
+
+import { getOrganizationPlanSeats } from '@/lib/billing/application/organization-billing/get-organization-plan-seats'
+import { GET } from '@/app/api/organizations/[id]/billing-plan/route'
+
+const schemaName = `plan_seats_${generateId().replaceAll('-', '')}`
+const connection = postgres(readTestDatabaseUrl(), {
+ max: 4,
+ prepare: false,
+ connection: { search_path: schemaName },
+ onnotice: () => undefined,
+})
+const database = drizzle(connection, { schema }) as typeof db
+const principal = { kind: 'session', userId: 'owner', sessionId: 'fixture-session' } as const
+const read = (organizationId = 'org-a') =>
+ getOrganizationPlanSeats.execute({ principal, input: { organizationId } })
+let apiUrl = ''
+const server = createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url ?? '/', apiUrl)
+ const result = await GET(new NextRequest(url, { method: request.method }), {
+ params: Promise.resolve({ id: url.pathname.split('/')[3] }),
+ })
+ response.writeHead(result.status, Object.fromEntries(result.headers))
+ response.end(await result.text())
+ } catch {
+ response.writeHead(500).end()
+ }
+})
+
+beforeAll(async () => {
+ await connection.unsafe(`CREATE SCHEMA "${schemaName}"`)
+ for (const table of [
+ 'organization',
+ 'member',
+ 'subscription',
+ 'user',
+ 'user_stats',
+ 'invitation',
+ 'outbox_event',
+ ]) {
+ await connection.unsafe(
+ `CREATE TABLE "${schemaName}"."${table}" (LIKE public."${table}" INCLUDING ALL)`
+ )
+ }
+ primary.select.mockImplementation((fields) => database.select(fields))
+ primary.execute.mockImplementation((query) => database.execute(query))
+ primary.transaction.mockImplementation((callback, options) =>
+ database.transaction(callback, options)
+ )
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('API fixture did not bind')
+ apiUrl = `http://127.0.0.1:${address.port}`
+})
+
+beforeEach(async () => {
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: principal.userId },
+ session: { id: principal.sessionId },
+ })
+ await connection`TRUNCATE member, subscription, "user", user_stats, invitation, outbox_event, organization`
+ await connection`INSERT INTO organization (id, name, slug, created_at) VALUES ('org-a', 'Fixture A', 'fixture-a', now()), ('org-b', 'Fixture B', 'fixture-b', now())`
+ await connection`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) VALUES ('owner', 'Owner', 'owner@example.test', false, now(), now()), ('member', 'Member', 'member@example.test', false, now(), now()), ('foreign', 'Foreign', 'foreign@example.test', false, now(), now())`
+ await connection`INSERT INTO member (id, user_id, organization_id, role) VALUES ('owner-a', 'owner', 'org-a', 'owner'), ('member-a', 'member', 'org-a', 'member'), ('foreign-b', 'foreign', 'org-b', 'owner')`
+ await connection`INSERT INTO subscription (id, reference_id, plan, status, seats, metadata) VALUES ('sub-a', 'org-a', 'enterprise', 'active', 1, '{"seats":7}'), ('sub-b', 'org-b', 'team', 'active', 99, null)`
+ await connection`INSERT INTO invitation (id, email, inviter_id, organization_id, role, token, expires_at, membership_intent, status) VALUES
+ ('internal', 'new@example.test', 'owner', 'org-a', 'member', 'internal', now() + interval '1 day', 'internal', 'pending'),
+ ('external', 'external@example.test', 'owner', 'org-a', 'member', 'external', now() + interval '1 day', 'external', 'pending'),
+ ('expired', 'expired@example.test', 'owner', 'org-a', 'member', 'expired', now() - interval '1 day', 'internal', 'pending'),
+ ('accepted', 'accepted@example.test', 'owner', 'org-a', 'member', 'accepted', now() + interval '1 day', 'internal', 'accepted'),
+ ('existing', ' MEMBER@example.test ', 'owner', 'org-a', 'member', 'existing', now() + interval '1 day', 'internal', 'pending'),
+ ('foreign', 'foreign@example.test', 'owner', 'org-a', 'member', 'foreign', now() + interval '1 day', 'internal', 'pending')`
+})
+
+afterAll(async () => {
+ const closed = once(server, 'close')
+ server.close()
+ server.closeAllConnections()
+ await closed
+ await connection.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
+ await connection.end()
+})
+
+describe('organization plan and seats without a usage ledger', () => {
+ it('serves authorized metadata over HTTP and refuses anonymous and foreign-organization reads', async () => {
+ const authorized = await fetch(`${apiUrl}/api/organizations/org-a/billing-plan`)
+ expect(authorized.status).toBe(200)
+ expect(await authorized.json()).toMatchObject({
+ success: true,
+ data: { organizationId: 'org-a', totalSeats: 7, usedSeats: 3 },
+ })
+ const foreign = await fetch(`${apiUrl}/api/organizations/org-b/billing-plan`)
+ expect(foreign.status).toBe(403)
+ expect(await foreign.json()).not.toHaveProperty('data')
+ authMockFns.mockGetSession.mockResolvedValue(null)
+ const anonymous = await fetch(`${apiUrl}/api/organizations/org-a/billing-plan`)
+ expect(anonymous.status).toBe(401)
+ expect(await anonymous.json()).not.toHaveProperty('data')
+ })
+
+ it.each([
+ { code: '40001', message: 'fixture recovery conflict', status: 503 },
+ { code: '57014', message: 'canceling statement due to statement timeout', status: 503 },
+ { code: '25P04', message: 'terminating connection due to transaction timeout', status: 503 },
+ { code: '57014', message: 'canceling statement due to user request', status: 500 },
+ { code: '42P01', message: 'fixture permanent database error', status: 500 },
+ ])(
+ 'projects $code over HTTP without leaking database details',
+ async ({ code, message, status }) => {
+ await connection`ALTER TABLE subscription RENAME TO unavailable_subscription`
+ try {
+ await connection.unsafe(`CREATE OR REPLACE FUNCTION fail_billing_read() RETURNS boolean
+ LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION USING ERRCODE = '${code}', MESSAGE = '${message}'; END $$;
+ CREATE VIEW subscription AS SELECT * FROM unavailable_subscription WHERE fail_billing_read()`)
+ const result = await fetch(`${apiUrl}/api/organizations/org-a/billing-plan`)
+ expect(result.status).toBe(status)
+ expect(result.headers.get('retry-after')).toBe(status === 503 ? '5' : null)
+ const body = await result.json()
+ expect(body).not.toHaveProperty('data')
+ expect(JSON.stringify(body)).not.toContain(message)
+ expect(JSON.stringify(body)).not.toContain('subscription')
+ } finally {
+ await connection`DROP VIEW IF EXISTS subscription`
+ await connection`ALTER TABLE unavailable_subscription RENAME TO subscription`
+ }
+ }
+ )
+
+ it('reports Enterprise capacity and only seat-consuming reservations without usage SQL', async () => {
+ await expect(read()).resolves.toMatchObject({
+ organizationId: 'org-a',
+ subscriptionPlan: 'enterprise',
+ totalSeats: 7,
+ membersTotal: 2,
+ usedSeats: 3,
+ hasEnterprisePlan: true,
+ })
+ })
+
+ it('denies cross-organization and non-admin reads before exposing payer data', async () => {
+ await expect(read('org-b')).rejects.toMatchObject({ code: 'forbidden' })
+ await expect(
+ getOrganizationPlanSeats.execute({
+ principal: { ...principal, userId: 'member' },
+ input: { organizationId: 'org-a' },
+ })
+ ).rejects.toMatchObject({ code: 'forbidden' })
+ })
+
+ it.each(['past_due', 'canceled'])(
+ 'does not grant SSO access to a %s subscription',
+ async (status) => {
+ await connection`UPDATE subscription SET status = ${status} WHERE id = 'sub-a'`
+ await expect(read()).resolves.toMatchObject({
+ hasEnterprisePlan: false,
+ subscriptionPlan: status === 'past_due' ? 'enterprise' : null,
+ })
+ }
+ )
+
+ it('withholds Enterprise features when the payer is billing-blocked', async () => {
+ await connection`INSERT INTO user_stats (id, user_id, billing_blocked, billing_blocked_reason) VALUES ('owner-stats', 'owner', true, 'payment_failed')`
+ await expect(read()).resolves.toMatchObject({
+ subscriptionPlan: 'enterprise',
+ hasEnterprisePlan: false,
+ totalSeats: 7,
+ })
+ })
+
+ it('propagates a failed subscription read instead of inventing a free plan', async () => {
+ await connection`ALTER TABLE subscription RENAME TO unavailable_subscription`
+ try {
+ await expect(read()).rejects.toThrow()
+ } finally {
+ await connection`ALTER TABLE unavailable_subscription RENAME TO subscription`
+ }
+ })
+})
diff --git a/apps/sim/lib/billing/application/organization-billing/get-organization-plan-seats.ts b/apps/sim/lib/billing/application/organization-billing/get-organization-plan-seats.ts
new file mode 100644
index 00000000000..b694470be52
--- /dev/null
+++ b/apps/sim/lib/billing/application/organization-billing/get-organization-plan-seats.ts
@@ -0,0 +1,58 @@
+import { db } from '@sim/db'
+import { member, organization } from '@sim/db/schema'
+import { count, eq } from 'drizzle-orm'
+import { defineAuthorizedOrganizationBillingUseCase } from '@/lib/billing/application/organization-billing/authorized-organization-billing-use-case'
+import { organizationBillingOperations } from '@/lib/billing/application/organization-billing/operations'
+import { getOrganizationSubscription } from '@/lib/billing/core/billing'
+import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
+import {
+ countPendingSeatInvitations,
+ resolveSeatCapacity,
+} from '@/lib/billing/validation/seat-management'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+
+interface OrganizationPlanSeatsInput {
+ organizationId: string
+}
+
+interface OrganizationPlanSeatsResult {
+ organizationId: string
+ subscriptionPlan: string | null
+ subscriptionStatus: string | null
+ totalSeats: number
+ usedSeats: number
+ membersTotal: number
+ hasEnterprisePlan: boolean
+}
+
+/** Primary-backed plan, feature access, and seat reservations, independent of usage analytics. */
+export const getOrganizationPlanSeats = defineAuthorizedOrganizationBillingUseCase({
+ operation: organizationBillingOperations.planSeats,
+ organizationId: (input: OrganizationPlanSeatsInput) => input.organizationId,
+ async execute({ context }): Promise {
+ const { organizationId } = context
+ const [organizations, subscription, memberCounts, pendingSeats, hasEnterprisePlan] =
+ await Promise.all([
+ db
+ .select({ id: organization.id })
+ .from(organization)
+ .where(eq(organization.id, organizationId))
+ .limit(1),
+ getOrganizationSubscription(organizationId, { executor: db, onError: 'throw' }),
+ db.select({ total: count() }).from(member).where(eq(member.organizationId, organizationId)),
+ countPendingSeatInvitations(organizationId, db),
+ isOrganizationOnEnterprisePlan(organizationId, 'throw', db),
+ ])
+ if (!organizations[0]) throw new OrchestrationError('not_found', 'Organization not found')
+ const membersTotal = memberCounts[0]?.total ?? 0
+ return {
+ organizationId,
+ subscriptionPlan: subscription?.plan ?? null,
+ subscriptionStatus: subscription?.status ?? null,
+ totalSeats: subscription ? await resolveSeatCapacity(subscription, db) : 0,
+ usedSeats: membersTotal + pendingSeats,
+ membersTotal,
+ hasEnterprisePlan,
+ }
+ },
+})
diff --git a/apps/sim/lib/billing/application/organization-billing/operations.ts b/apps/sim/lib/billing/application/organization-billing/operations.ts
new file mode 100644
index 00000000000..8bf46e97c15
--- /dev/null
+++ b/apps/sim/lib/billing/application/organization-billing/operations.ts
@@ -0,0 +1,46 @@
+import type { Principal } from '@sim/auth/principal'
+import type { ApplicationOperation } from '@/lib/core/application'
+import { assertOperationCapability } from '@/lib/core/application/operation'
+
+/** Acting session or delegated organization identity; workspace keys cannot manage billing. */
+export type OrganizationBillingPrincipal = Extract<
+ Principal,
+ { kind: 'session' | 'organization_delegated' }
+>
+
+/** Organization billing policy requiring current administrator or owner membership. */
+export interface OrganizationBillingOperation
+ extends ApplicationOperation {
+ readonly organizationRoles: readonly ['admin', 'owner']
+ readonly workspaceApiKey: 'deny'
+ readonly principalKinds: readonly ['session', 'organization_delegated']
+}
+
+function defineOrganizationBillingOperation(
+ operation: OrganizationBillingOperation
+): OrganizationBillingOperation {
+ assertOperationCapability(operation)
+ Object.freeze(operation.organizationRoles)
+ Object.freeze(operation.principalKinds)
+ return Object.freeze(operation)
+}
+
+/** Shared authorization policies for billing summaries and lightweight plan/seat reads. */
+export const organizationBillingOperations = {
+ // permission-group-exempt: plan and seat management requires current organization administrator authority.
+ planSeats: defineOrganizationBillingOperation({
+ id: 'organization_billing.plan_seats.read',
+ organizationRoles: ['admin', 'owner'],
+ workspaceApiKey: 'deny',
+ principalKinds: ['session', 'organization_delegated'],
+ capability: 'none',
+ }),
+ // permission-group-exempt: an organization-admin surface — admins and owners sit above every group, and no group key names organization billing
+ read: defineOrganizationBillingOperation({
+ id: 'organization_billing.summary.read',
+ organizationRoles: ['admin', 'owner'],
+ workspaceApiKey: 'deny',
+ principalKinds: ['session', 'organization_delegated'],
+ capability: 'none',
+ }),
+} as const
diff --git a/apps/sim/lib/billing/application/usage-limits.test.ts b/apps/sim/lib/billing/application/usage-limits.test.ts
index 870b2ee4b8c..fc8e80c9f5e 100644
--- a/apps/sim/lib/billing/application/usage-limits.test.ts
+++ b/apps/sim/lib/billing/application/usage-limits.test.ts
@@ -27,10 +27,9 @@ vi.mock('@/lib/billing', () => ({
updateUserUsageLimit: hoisted.userUpdate,
}))
vi.mock('@/lib/billing/core/organization', () => billingOrganizationMock)
-vi.mock(
- '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary',
- () => ({ getOrganizationBillingSummary: { execute: hoisted.summary } })
-)
+vi.mock('@/lib/billing/application/organization-billing/get-organization-billing-summary', () => ({
+ getOrganizationBillingSummary: { execute: hoisted.summary },
+}))
vi.mock('@/lib/billing/organizations/member-limits', () => organizationMemberLimitsMock)
vi.mock('@/lib/billing/core/billing', () => billingCoreMock)
vi.mock('@/lib/permission-groups/resolve.server', () => permissionGroupsResolveMock)
diff --git a/apps/sim/lib/billing/application/usage-limits.ts b/apps/sim/lib/billing/application/usage-limits.ts
index 98484d815bb..50f7c352c6c 100644
--- a/apps/sim/lib/billing/application/usage-limits.ts
+++ b/apps/sim/lib/billing/application/usage-limits.ts
@@ -1,7 +1,7 @@
import type { Principal } from '@sim/auth/principal'
import { dbReplica } from '@sim/db'
import { getUserUsageLimitInfo, updateUserUsageLimit } from '@/lib/billing'
-import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
+import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing/get-organization-billing-summary'
import { organizationBillingSettingsActor } from '@/lib/billing/application/organization-settings-actor'
import {
type UsageLimitReadInput,
diff --git a/apps/sim/lib/billing/core/ledger-read.integration.ts b/apps/sim/lib/billing/core/ledger-read.integration.ts
new file mode 100644
index 00000000000..4a99f4c6c53
--- /dev/null
+++ b/apps/sim/lib/billing/core/ledger-read.integration.ts
@@ -0,0 +1,135 @@
+import type { db } from '@sim/db'
+import * as schema from '@sim/db/schema'
+import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
+import { getPostgresErrorCode } from '@sim/utils/errors'
+import { sleep } from '@sim/utils/helpers'
+import { generateId } from '@sim/utils/id'
+import { sql } from 'drizzle-orm'
+import { drizzle } from 'drizzle-orm/postgres-js'
+import postgres from 'postgres'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
+import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
+
+vi.mock('@/lib/billing/constants', () => ({
+ USAGE_LEDGER_STATEMENT_TIMEOUT_MS: 1000,
+}))
+
+const schemaName = `ledger_read_${generateId().replaceAll('-', '')}`
+const connection = postgres(readTestDatabaseUrl(), {
+ max: 1,
+ prepare: false,
+ connection: { search_path: schemaName },
+ onnotice: () => undefined,
+})
+const database = drizzle(connection, { schema }) as typeof db
+
+beforeAll(async () => {
+ await connection.unsafe(`CREATE SCHEMA "${schemaName}"`)
+ await connection.unsafe(`CREATE TABLE ledger_probe (cost numeric NOT NULL);
+ CREATE FUNCTION fail_read(code text) RETURNS numeric LANGUAGE plpgsql AS $$
+ BEGIN RAISE EXCEPTION USING ERRCODE = code, MESSAGE = 'fixture database failure'; END $$;`)
+})
+
+beforeEach(async () => {
+ await connection`TRUNCATE ledger_probe`
+ await connection`INSERT INTO ledger_probe VALUES (0.25), (0.75)`
+})
+
+afterAll(async () => {
+ await connection.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
+ await connection.end()
+})
+
+describe('bounded ledger reads with PostgreSQL', () => {
+ it('rolls back a recovery conflict and reads the ledger in a fresh transaction', async () => {
+ const transactions: string[] = []
+ const timeouts: number[] = []
+ const total = await readLedgerBounded(database, async (tx) => {
+ const rows = await tx.execute<{ transaction: string; timeout: string }>(
+ sql`select txid_current()::text as transaction,
+ extract(epoch from current_setting('statement_timeout')::interval) * 1000 as timeout`
+ )
+ transactions.push(rows[0].transaction)
+ timeouts.push(Number(rows[0].timeout))
+ if (transactions.length === 1) await tx.execute(sql`select fail_read('40001')`)
+ const sums = await tx.execute<{ total: string }>(
+ sql`select sum(cost) as total from ledger_probe`
+ )
+ return Number(sums[0].total)
+ })
+ expect(total).toBe(1)
+ expect(transactions).toHaveLength(2)
+ expect(new Set(transactions).size).toBe(2)
+ expect(timeouts.every((timeout) => timeout > 0)).toBe(true)
+ expect(timeouts[1]).toBeLessThan(timeouts[0])
+ expect(await connection`select sum(cost)::text as total from ledger_probe`).toEqual([
+ { total: '1.00' },
+ ])
+ })
+
+ it('deducts time waiting for a pooled connection from the statement budget', async () => {
+ let observeRead: Promise | undefined
+ await connection.begin(async () => {
+ observeRead = readLedgerBounded(database, async (tx) => {
+ const rows = await tx.execute<{ timeout: string }>(
+ sql`select extract(epoch from current_setting('statement_timeout')::interval) * 1000 as timeout`
+ )
+ return Number(rows[0].timeout)
+ })
+ await sleep(100)
+ })
+ if (!observeRead) throw new Error('Ledger read was not started')
+ expect(await observeRead).toBeLessThanOrEqual(USAGE_LEDGER_STATEMENT_TIMEOUT_MS - 80)
+ })
+
+ it('rejects a ledger read whose connection wait exhausted the budget', async () => {
+ let outcome: Promise | undefined
+ await connection.begin(async () => {
+ outcome = readLedgerBounded(database, async (tx) => {
+ const rows = await tx.execute<{ total: string }>(
+ sql`select sum(cost) as total from ledger_probe`
+ )
+ return Number(rows[0].total)
+ })
+ void outcome.catch(() => undefined)
+ await sleep(USAGE_LEDGER_STATEMENT_TIMEOUT_MS + 100)
+ })
+ if (!outcome) throw new Error('Ledger read was not started')
+ await expect(outcome).rejects.toThrow('Database read deadline exceeded')
+ expect(await connection`select sum(cost)::text as total from ledger_probe`).toEqual([
+ { total: '1.00' },
+ ])
+ })
+
+ it('cannot replay a write under the read-retry policy', async () => {
+ const error = await readLedgerBounded(database, async (tx) => {
+ await tx.execute(sql`insert into ledger_probe values (999)`)
+ return 999
+ }).catch((error: unknown) => error)
+ expect(getPostgresErrorCode(error)).toBe('25006')
+ expect(await connection`select sum(cost)::text as total from ledger_probe`).toEqual([
+ { total: '1.00' },
+ ])
+ })
+
+ it.each([
+ { code: '40001', attempts: 2 },
+ { code: '57014', attempts: 1 },
+ { code: '42P01', attempts: 1 },
+ ])(
+ 'bounds attempts for $code and releases the failed transaction',
+ async ({ code, attempts }) => {
+ let reads = 0
+ const error = await readLedgerBounded(database, async (tx) => {
+ reads++
+ await tx.execute(sql`select fail_read(${code})`)
+ }).catch((error: unknown) => error)
+ expect(getPostgresErrorCode(error)).toBe(code)
+ expect(reads).toBe(attempts)
+ expect(await connection`select sum(cost)::text as total from ledger_probe`).toEqual([
+ { total: '1.00' },
+ ])
+ }
+ )
+})
diff --git a/apps/sim/lib/billing/core/ledger-read.ts b/apps/sim/lib/billing/core/ledger-read.ts
index 577d1b7b5d0..ef1b70cf097 100644
--- a/apps/sim/lib/billing/core/ledger-read.ts
+++ b/apps/sim/lib/billing/core/ledger-read.ts
@@ -1,10 +1,12 @@
import { sql } from 'drizzle-orm'
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
+import { DatabaseReadDeadlineError, withDatabaseReadRetry } from '@/lib/db/read-retry'
import type { DbClient, DbTransaction } from '@/lib/db/types'
/**
- * Runs one aggregate over a payer's usage ledger in a transaction of its own, bounded by
- * {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS}. `SET LOCAL` scopes the bound to that transaction,
+ * Runs one aggregate over a payer's usage ledger in a transaction of its own, sharing
+ * {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS} across retries and statement execution. Pool wait
+ * is deducted on acquisition; an exhausted budget refuses the read. `SET LOCAL` scopes the bound,
* so it ends with the read and never reaches the pool. Every sum over a payer's billing period
* reads through here, whether it admits a run, closes a cycle or previews a bill: a payer whose
* period has grown past what one statement can sum within the bound fails at the database
@@ -16,10 +18,21 @@ export function readLedgerBounded(
executor: DbClient,
read: (tx: DbTransaction) => Promise
): Promise {
- return executor.transaction(async (tx) => {
- await tx.execute(
- sql.raw(`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`)
- )
- return read(tx)
- })
+ return withDatabaseReadRetry(
+ (remainingMs) => {
+ const attemptStartedAt = Date.now()
+ return executor.transaction(
+ async (tx) => {
+ const timeoutMs = Math.floor(
+ (remainingMs ?? USAGE_LEDGER_STATEMENT_TIMEOUT_MS) - (Date.now() - attemptStartedAt)
+ )
+ if (timeoutMs <= 0) throw new DatabaseReadDeadlineError()
+ await tx.execute(sql.raw(`SET LOCAL statement_timeout = '${timeoutMs}ms'`))
+ return read(tx)
+ },
+ { accessMode: 'read only' }
+ )
+ },
+ { label: 'usage ledger', maxAttempts: 2, maxElapsedMs: USAGE_LEDGER_STATEMENT_TIMEOUT_MS }
+ )
}
diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts
index d33afcd4753..0998f1b640d 100644
--- a/apps/sim/lib/billing/core/organization.ts
+++ b/apps/sim/lib/billing/core/organization.ts
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, count, eq, gte, lt, sql } from 'drizzle-orm'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
+import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
import { resolveSubscriptionUsagePeriodOrDefault } from '@/lib/billing/core/reporting-period'
import {
getBillingPeriodUsageCost,
@@ -80,7 +81,10 @@ export async function getOrgMemberLedgerByUser(
): Promise> {
let billingPeriod = period ?? null
if (period === undefined) {
- const subscription = await getOrganizationSubscription(organizationId, { executor })
+ const subscription = await getOrganizationSubscription(organizationId, {
+ executor,
+ onError: 'throw',
+ })
billingPeriod = subscription ? resolveSubscriptionUsagePeriodOrDefault(subscription) : null
}
if (!billingPeriod) return new Map()
@@ -106,54 +110,56 @@ async function getOrganizationMemberUsageCounts(
billingPeriod: UsageQueryPeriod,
executor: DbClient
): Promise<{ overLimit: number; nearLimit: number }> {
- const currentUsage = sql`coalesce(sum(${usageLog.cost}), 0)`
- .mapWith(Number)
- .as('current_usage')
- const usageLimit = sql`coalesce(${userStats.currentUsageLimit}, ${getFreeTierLimit()})`
- .mapWith(Number)
- .as('usage_limit')
- const perMemberUsage = executor
- .select({ currentUsage, usageLimit })
- .from(member)
- .leftJoin(userStats, eq(userStats.userId, member.userId))
- .leftJoin(
- usageLog,
- and(
- eq(usageLog.userId, member.userId),
- eq(usageLog.billingEntityType, 'organization'),
- eq(usageLog.billingEntityId, organizationId),
- ...(billingPeriod.source === 'reporting'
- ? [
- gte(usageLog.createdAt, billingPeriod.start),
- lt(usageLog.createdAt, billingPeriod.end),
- ]
- : [
- eq(usageLog.billingPeriodStart, billingPeriod.start),
- eq(usageLog.billingPeriodEnd, billingPeriod.end),
- ])
+ return readLedgerBounded(executor, async (tx) => {
+ const currentUsage = sql`coalesce(sum(${usageLog.cost}), 0)`
+ .mapWith(Number)
+ .as('current_usage')
+ const usageLimit = sql`coalesce(${userStats.currentUsageLimit}, ${getFreeTierLimit()})`
+ .mapWith(Number)
+ .as('usage_limit')
+ const perMemberUsage = tx
+ .select({ currentUsage, usageLimit })
+ .from(member)
+ .leftJoin(userStats, eq(userStats.userId, member.userId))
+ .leftJoin(
+ usageLog,
+ and(
+ eq(usageLog.userId, member.userId),
+ eq(usageLog.billingEntityType, 'organization'),
+ eq(usageLog.billingEntityId, organizationId),
+ ...(billingPeriod.source === 'reporting'
+ ? [
+ gte(usageLog.createdAt, billingPeriod.start),
+ lt(usageLog.createdAt, billingPeriod.end),
+ ]
+ : [
+ eq(usageLog.billingPeriodStart, billingPeriod.start),
+ eq(usageLog.billingPeriodEnd, billingPeriod.end),
+ ])
+ )
)
- )
- .where(eq(member.organizationId, organizationId))
- .groupBy(member.userId, userStats.currentUsageLimit)
- .as('organization_member_usage')
-
- const [counts] = await executor
- .select({
- overLimit:
- sql`count(*) filter (where ${perMemberUsage.currentUsage} > ${perMemberUsage.usageLimit})`.mapWith(
- Number
- ),
- nearLimit:
- sql`count(*) filter (where ${perMemberUsage.usageLimit} > 0 and ${perMemberUsage.currentUsage} <= ${perMemberUsage.usageLimit} and ${perMemberUsage.currentUsage} / ${perMemberUsage.usageLimit} >= 0.8)`.mapWith(
- Number
- ),
- })
- .from(perMemberUsage)
+ .where(eq(member.organizationId, organizationId))
+ .groupBy(member.userId, userStats.currentUsageLimit)
+ .as('organization_member_usage')
+
+ const [counts] = await tx
+ .select({
+ overLimit:
+ sql`count(*) filter (where ${perMemberUsage.currentUsage} > ${perMemberUsage.usageLimit})`.mapWith(
+ Number
+ ),
+ nearLimit:
+ sql`count(*) filter (where ${perMemberUsage.usageLimit} > 0 and ${perMemberUsage.currentUsage} <= ${perMemberUsage.usageLimit} and ${perMemberUsage.currentUsage} / ${perMemberUsage.usageLimit} >= 0.8)`.mapWith(
+ Number
+ ),
+ })
+ .from(perMemberUsage)
- return {
- overLimit: counts?.overLimit ?? 0,
- nearLimit: counts?.nearLimit ?? 0,
- }
+ return {
+ overLimit: counts?.overLimit ?? 0,
+ nearLimit: counts?.nearLimit ?? 0,
+ }
+ })
}
/**
@@ -168,7 +174,10 @@ export async function getOrganizationMemberUsageSnapshot(
} = {}
): Promise {
const executor = options.executor ?? db
- const subscription = await getOrganizationSubscription(organizationId, { executor })
+ const subscription = await getOrganizationSubscription(organizationId, {
+ executor,
+ onError: 'throw',
+ })
const billingPeriod = subscription ? resolveSubscriptionUsagePeriodOrDefault(subscription) : null
return {
billingPeriod,
@@ -178,14 +187,28 @@ export async function getOrganizationMemberUsageSnapshot(
}
}
-/**
- * Get comprehensive organization billing and usage data
- */
+type OrganizationBillingOverview = Omit<
+ OrganizationUsageData,
+ 'membersOverLimit' | 'membersNearLimit'
+>
+type OrganizationBillingMemberPage = { limit?: number; offset?: number }
+
+/** Reads billing and usage, optionally omitting the unpaginated member-limit aggregate. */
+export function getOrganizationBillingData(
+ organizationId: string,
+ executor: DbClient,
+ memberPage: OrganizationBillingMemberPage & { includeMemberUsageCounts: false }
+): Promise
+export function getOrganizationBillingData(
+ organizationId: string,
+ executor?: DbClient,
+ memberPage?: OrganizationBillingMemberPage & { includeMemberUsageCounts?: true }
+): Promise
export async function getOrganizationBillingData(
organizationId: string,
executor: DbClient = db,
- memberPage: { limit?: number; offset?: number } = {}
-): Promise {
+ memberPage: OrganizationBillingMemberPage & { includeMemberUsageCounts?: boolean } = {}
+): Promise {
try {
// Get organization info
const orgRecord = await executor
@@ -202,7 +225,10 @@ export async function getOrganizationBillingData(
const organizationData = orgRecord[0]
// Get organization subscription directly (referenceId = organizationId)
- const subscription = await getOrganizationSubscription(organizationId, { executor })
+ const subscription = await getOrganizationSubscription(organizationId, {
+ executor,
+ onError: 'throw',
+ })
if (!subscription) {
logger.warn('No subscription found for organization', { organizationId })
@@ -320,9 +346,12 @@ export async function getOrganizationBillingData(
const pendingSeats = await countPendingSeatInvitations(organizationId, executor)
const usedSeats = membersTotal + pendingSeats
- const memberUsageCounts = billingPeriod
- ? await getOrganizationMemberUsageCounts(organizationId, billingPeriod, executor)
- : { overLimit: 0, nearLimit: 0 }
+ const memberUsageCounts =
+ memberPage.includeMemberUsageCounts === false
+ ? null
+ : billingPeriod
+ ? await getOrganizationMemberUsageCounts(organizationId, billingPeriod, executor)
+ : { overLimit: 0, nearLimit: 0 }
const billingPeriodStart = billingPeriod?.start ?? null
const billingPeriodEnd = billingPeriod?.end ?? null
@@ -348,8 +377,12 @@ export async function getOrganizationBillingData(
offset,
hasMore: offset + members.length < membersTotal,
},
- membersOverLimit: memberUsageCounts.overLimit,
- membersNearLimit: memberUsageCounts.nearLimit,
+ ...(memberUsageCounts
+ ? {
+ membersOverLimit: memberUsageCounts.overLimit,
+ membersNearLimit: memberUsageCounts.nearLimit,
+ }
+ : {}),
members,
}
} catch (error) {
diff --git a/apps/sim/lib/db/read-retry.test.ts b/apps/sim/lib/db/read-retry.test.ts
index a69199046ed..973cbce6cae 100644
--- a/apps/sim/lib/db/read-retry.test.ts
+++ b/apps/sim/lib/db/read-retry.test.ts
@@ -1,3 +1,4 @@
+import { sleep } from '@sim/utils/helpers'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isTransientDatabaseReadError, withDatabaseReadRetry } from '@/lib/db/read-retry'
@@ -8,6 +9,44 @@ function driverError(code: string): Error {
afterEach(() => vi.useRealTimers())
describe('independent database read retries', () => {
+ it('does not start another read after the elapsed budget is spent', async () => {
+ vi.useFakeTimers()
+ const error = driverError('40001')
+ let attempts = 0
+ const result = expect(
+ withDatabaseReadRetry(
+ async () => {
+ attempts++
+ if (attempts > 1) return 'late recovery'
+ await sleep(100)
+ throw error
+ },
+ { maxElapsedMs: 50 }
+ )
+ ).rejects.toBe(error)
+ await vi.runAllTimersAsync()
+ await result
+ expect(attempts).toBe(1)
+ })
+
+ it('supplies the remaining budget to each fresh read', async () => {
+ vi.useFakeTimers()
+ const budgets: Array = []
+ const result = withDatabaseReadRetry(
+ async (remainingMs) => {
+ budgets.push(remainingMs)
+ if (budgets.length === 1) throw driverError('40001')
+ return 'recovered'
+ },
+ { maxElapsedMs: 1000, maxAttempts: 2 }
+ )
+ await vi.runAllTimersAsync()
+ await expect(result).resolves.toBe('recovered')
+ expect(budgets[0]).toBe(1000)
+ expect(budgets[1]).toBeGreaterThan(0)
+ expect(budgets[1]).toBeLessThan(1000)
+ })
+
it.each(['08006', '57P01', '53300', '55P03', 'ECONNRESET', 'CONNECTION_CLOSED'])(
'rebuilds a failed %s read and returns the recovered rows',
async (code) => {
diff --git a/apps/sim/lib/db/read-retry.ts b/apps/sim/lib/db/read-retry.ts
index ba8327b99ef..fc454afe692 100644
--- a/apps/sim/lib/db/read-retry.ts
+++ b/apps/sim/lib/db/read-retry.ts
@@ -27,6 +27,14 @@ const TRANSIENT_READ_CODES = new Set([
'ETIMEDOUT',
])
+/** A read exhausted its budget before its query could begin. */
+export class DatabaseReadDeadlineError extends Error {
+ constructor() {
+ super('Database read deadline exceeded')
+ this.name = 'DatabaseReadDeadlineError'
+ }
+}
+
/** Only driver codes identify retryable reads; query cancellation and application errors propagate. */
export function isTransientDatabaseReadError(error: unknown): boolean {
const code = getPostgresErrorCode(error)
@@ -34,28 +42,42 @@ export function isTransientDatabaseReadError(error: unknown): boolean {
}
/**
- * Retries an independent, read-only statement at most three times. The callback must
- * rebuild the query outside any transaction and must have no side effects or locks.
- * A failed connection cannot establish whether a write committed, so writes and
- * transactions must never use this helper.
+ * Retries independent reads, rebuilding each statement or fresh read-only transaction
+ * after the previous attempt has fully rolled back. Never retry writes, locking reads,
+ * or statements inside a caller-owned transaction.
*
- * `label` names the read in the retry log line so callers that wrap several can tell which flapped.
+ * `maxElapsedMs` bounds retry admission, including backoff. The callback receives the
+ * remaining budget and must enforce it at the database to bound an in-flight statement.
*/
export async function withDatabaseReadRetry(
- read: () => Promise,
- options: { label?: string } = {}
+ read: (remainingMs?: number) => Promise,
+ options: { label?: string; maxAttempts?: number; maxElapsedMs?: number } = {}
): Promise {
+ const maxAttempts = options.maxAttempts ?? 3
+ const deadline =
+ options.maxElapsedMs === undefined ? undefined : Date.now() + options.maxElapsedMs
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1)
+ throw new Error('Read attempts must be positive')
+ if (
+ options.maxElapsedMs !== undefined &&
+ (!Number.isFinite(options.maxElapsedMs) || options.maxElapsedMs <= 0)
+ ) {
+ throw new Error('Read budget must be positive')
+ }
for (let attempt = 1; ; attempt++) {
try {
- return await read()
+ return await read(deadline === undefined ? undefined : Math.max(1, deadline - Date.now()))
} catch (error) {
- if (attempt >= 3 || !isTransientDatabaseReadError(error)) throw error
+ if (attempt >= maxAttempts || !isTransientDatabaseReadError(error)) throw error
+ const delay = backoffWithJitter(attempt, null, { baseMs: 100, maxMs: 500 })
+ if (deadline !== undefined && Date.now() + delay >= deadline) throw error
logger.warn('Retrying transient database read', {
label: options.label,
attempt,
code: getPostgresErrorCode(error),
})
- await sleep(backoffWithJitter(attempt, null, { baseMs: 100, maxMs: 500 }))
+ await sleep(delay)
+ if (deadline !== undefined && Date.now() >= deadline) throw error
}
}
}
diff --git a/apps/sim/lib/mothership/tools/server/settings.ts b/apps/sim/lib/mothership/tools/server/settings.ts
index b3bf146eda3..a769a8bb7d2 100644
--- a/apps/sim/lib/mothership/tools/server/settings.ts
+++ b/apps/sim/lib/mothership/tools/server/settings.ts
@@ -18,7 +18,7 @@ import {
import { listPersonalApiKeys } from '@/lib/api-key/application/personal-api-keys'
import { listWorkspaceApiKeys } from '@/lib/api-key/application/workspace-api-keys'
import { listWorkspaceByokKeys } from '@/lib/api-key/application/workspace-byok-keys'
-import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
+import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing/get-organization-billing-summary'
import { getOrganizationUsageSummary } from '@/lib/billing/application/organization-usage/get-organization-usage-summary'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { getOrganizationAccountsSettings } from '@/lib/credential-groups/application/organization-accounts'
diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json
index 71d276c7065..ebf9dd2a52a 100644
--- a/scripts/check-unused-exports.baseline.json
+++ b/scripts/check-unused-exports.baseline.json
@@ -1144,7 +1144,6 @@
"apps/sim/hooks/queries/organization-usage.ts#ORGANIZATION_USAGE_BREAKDOWN_STALE_TIME",
"apps/sim/hooks/queries/organization-usage.ts#ORGANIZATION_USAGE_EVENTS_STALE_TIME",
"apps/sim/hooks/queries/organization-usage.ts#ORGANIZATION_USAGE_OVERVIEW_STALE_TIME",
- "apps/sim/hooks/queries/organization.ts#ORGANIZATION_BILLING_STALE_TIME",
"apps/sim/hooks/queries/organization.ts#ORGANIZATION_DETAIL_STALE_TIME",
"apps/sim/hooks/queries/organization.ts#ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME",
"apps/sim/hooks/queries/organization.ts#ORGANIZATION_REMOVAL_IMPACT_STALE_TIME",
@@ -4143,7 +4142,6 @@
"apps/sim/lib/auth/sso/domain-verification.ts#SSO_CHALLENGE_HOST_PREFIX",
"apps/sim/lib/billing/application/authorized-billing-read-use-case.ts#BillingReadScope",
"apps/sim/lib/billing/application/list-billing-logs.ts#BillingLogsScope",
- "apps/sim/lib/billing/application/organization-billing-summary/authorized-organization-billing-summary-use-case.ts#AuthorizedOrganizationBillingSummaryContext",
"apps/sim/lib/billing/application/organization-usage/export-organization-usage-events.ts#USAGE_EXPORT_SAFETY_CAP",
"apps/sim/lib/billing/application/organization-usage/get-organization-usage-breakdown.ts#OrganizationUsageBreakdownRow",
"apps/sim/lib/billing/application/organization-usage/list-organization-usage-events.ts#OrganizationUsageEvent",
diff --git a/scripts/test-patterns-baseline.json b/scripts/test-patterns-baseline.json
index dc44ea44c49..bff8abeda81 100644
--- a/scripts/test-patterns-baseline.json
+++ b/scripts/test-patterns-baseline.json
@@ -4,7 +4,6 @@
"global-remock\tapps/sim/app/api/schedules/execute/route.test.ts\t@sim/db",
"global-remock\tapps/sim/app/api/schedules/execute/route.test.ts\tdrizzle-orm",
"global-remock\tapps/sim/blocks/blocks/outlook.test.ts\t@/tools/registry",
- "global-remock\tapps/sim/lib/billing/application/organization-billing-summary/get-organization-billing-summary.test.ts\t@sim/db",
"global-remock\tapps/sim/lib/billing/storage/payer-transfer.test.ts\t@sim/db/schema",
"global-remock\tapps/sim/lib/billing/storage/payer-transfer.test.ts\tdrizzle-orm",
"global-remock\tapps/sim/lib/credentials/deletion.test.ts\t@sim/db",