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 `
{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",