Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/rules/sim-react-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ const [{ id }, { kbName }] = await Promise.all([params, searchParams])

Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read).

## Carry exact lifecycle ownership across async boundaries

When asynchronous work can outlive an execution, session, or resource instance, capture its
opaque ownership token before the first `await` and pass that exact token through completion and
error cleanup. Never re-adopt the current owner from delayed cleanup: a replacement may now own
the same scope. End the lifecycle by exact-token match, and clear shared state only when that end
succeeds. Current-owner adoption is reserved for synchronous user actions that explicitly stop
the current lifecycle.

## Prefetch dynamic destination lists on intent

For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume
Expand All @@ -99,6 +108,12 @@ server state with the consumer's shared React Query options. A short, cancelable
avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling;
let the actual unmodified click start the data request.

A speculative failure must not poison a later visit when the app default disables
`retryOnMount`: remove only that exact failed query while it is inactive, keep failures visible
to mounted consumers, and set the shared options to `retryOnMount: true` so a quick-click failure
can recover after the user leaves and returns. Never carry placeholder data between protected
resource keys (for example, workspace A to workspace B); an explicit loading state is truthful.

If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains
mounted until its peer is ready, the intent path must warm both the full route and its critical
data. Otherwise keep the loading boundary so dynamic navigation remains responsive.
Expand Down
5 changes: 5 additions & 0 deletions .claude/rules/sim-settings-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ Adding a new settings page:
2. Render the component inside the shell's `effectiveSection` switch in
`settings/[section]/settings.tsx`.
3. Build the component body inside `<SettingsPanel>` — no shell, no title block.
4. When a real second consumer or server boundary needs it, extract client-safe React Query options;
otherwise keep them with the hook. Approved intent warmers reuse those exact options and must keep
`check-tool-registry-boundary` green. Warm only authorized destinations, preserve the current
section during the transition, and follow `sim-react-performance.md` recovery rules; never render
temporary default data that will be replaced after load.

## Text-scale tokens (no literal pixel sizes)

Expand Down
20 changes: 15 additions & 5 deletions apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
Expand All @@ -9,9 +10,11 @@ import {
getSettingsSectionMeta,
parseSettingsPathSection,
} from '@/components/settings/navigation'
import { prefetchStandaloneGeneral } from '@/components/settings/prefetch-standalone-general'
import { getSession } from '@/lib/auth'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'

interface AccountSettingsSectionPageProps {
params: Promise<{ section: string }>
Expand Down Expand Up @@ -52,14 +55,21 @@ export default async function AccountSettingsSectionPage({
}

/**
* Sections read URL query params via nuqs (which uses `useSearchParams`
* internally), so the renderer must sit under a Suspense boundary. The
* `null` fallback matches the existing visual behavior — the sections are
* `next/dynamic` components that render nothing while their chunk loads.
* Sections read URL query params via nuqs, so the renderer must sit under a
* Suspense boundary. The null fallback preserves the existing chunk-loading UI.
*/
return (
const content = (
<Suspense fallback={null}>
<AccountSettingsRenderer section={parsed} />
</Suspense>
)

if (parsed === 'general') {
const queryClient = getQueryClient()
await prefetchStandaloneGeneral(queryClient)

return <HydrationBoundary state={dehydrate(queryClient)}>{content}</HydrationBoundary>
}

return content
}
3 changes: 1 addition & 2 deletions apps/sim/app/api/billing/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,8 @@ function mockOrganizationDbRows({
.mockResolvedValueOnce([{ role }])
.mockResolvedValueOnce([{ id: 'org-target', name: 'Target organization' }])
.mockResolvedValueOnce(latestSubscription ? [latestSubscription] : [])
.mockResolvedValueOnce([{ userId: ownerId }])
.mockResolvedValueOnce([{ userId: ownerId, billingBlocked, billingBlockedReason }])
.mockResolvedValueOnce(upgradeWorkspaceId ? [{ id: upgradeWorkspaceId }] : [])
.mockResolvedValueOnce([{ billingBlocked, billingBlockedReason }])
}

describe('GET /api/billing', () => {
Expand Down
78 changes: 5 additions & 73 deletions apps/sim/app/api/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,95 +3,27 @@ import {
member,
organization as organizationTable,
subscription as subscriptionTable,
userStats,
workspace as workspaceTable,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getBillingContract } from '@/lib/api/contracts/subscription'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getOrganizationSubscription, getPersonalBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import {
getOrganizationBillingBlockState,
getUpgradeWorkspaceId,
} from '@/lib/billing/core/payer-context'
import { resolveBillingInterval } from '@/lib/billing/core/subscription'
import { getCreditBalanceForEntity } from '@/lib/billing/credits/balance'
import { isPaid } from '@/lib/billing/plan-helpers'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('UnifiedBillingAPI')

interface BillingBlockState {
billingBlocked: boolean
billingBlockedReason: 'payment_failed' | 'dispute' | null
blockedByOrgOwner: boolean
}

/**
* Finds an active workspace whose host billing identity is the requested payer.
*/
async function getUpgradeWorkspaceId(
target: { type: 'user'; id: string } | { type: 'organization'; id: string }
): Promise<string | null> {
const targetPredicate =
target.type === 'organization'
? eq(workspaceTable.organizationId, target.id)
: and(
eq(workspaceTable.ownerId, target.id),
eq(workspaceTable.billedAccountUserId, target.id),
isNull(workspaceTable.organizationId)
)

const [workspace] = await dbReplica
.select({ id: workspaceTable.id })
.from(workspaceTable)
.where(and(targetPredicate, isNull(workspaceTable.archivedAt)))
.orderBy(asc(workspaceTable.createdAt), asc(workspaceTable.id))
.limit(1)

return workspace?.id ?? null
}

/**
* Reads the exact organization's payer block from its owner, without allowing
* the viewer's personal status or another organization membership to leak in.
*/
async function getOrganizationBillingBlockState(
organizationId: string,
viewerUserId: string
): Promise<BillingBlockState> {
const [owner] = await dbReplica
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)

if (!owner) {
return {
billingBlocked: false,
billingBlockedReason: null,
blockedByOrgOwner: false,
}
}

const [stats] = await dbReplica
.select({
billingBlocked: userStats.billingBlocked,
billingBlockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, owner.userId))
.limit(1)

const billingBlocked = Boolean(stats?.billingBlocked)
return {
billingBlocked,
billingBlockedReason: billingBlocked ? (stats?.billingBlockedReason ?? null) : null,
blockedByOrgOwner: billingBlocked && owner.userId !== viewerUserId,
}
}

/**
* Unified Billing Endpoint
*/
Expand Down
98 changes: 98 additions & 0 deletions apps/sim/app/api/organizations/[id]/billing-summary/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ForbiddenOperationError } from '@/lib/core/application'

const { mockReadBillingSummary } = vi.hoisted(() => ({
mockReadBillingSummary: vi.fn(),
}))

vi.mock(
'@/lib/billing/application/organization-billing-summary/get-organization-billing-summary',
() => ({
getOrganizationBillingSummary: {
operation: { id: 'organization_billing.summary.read' },
execute: mockReadBillingSummary,
},
})
)

import { GET } from '@/app/api/organizations/[id]/billing-summary/route'

const routeContext = { params: Promise.resolve({ id: 'organization-1' }) }
const summary = {
organizationId: 'organization-1',
subscriptionState: 'active' as const,
subscriptionPlan: 'team',
subscriptionStatus: 'active',
creditBalance: 10,
billingInterval: 'month' as const,
cancelAtPeriodEnd: false,
totalSeats: 3,
totalCurrentUsage: 25,
totalUsageLimit: 100,
minimumBillingAmount: 60,
billingPeriodEnd: '2026-09-30T00:00:00.000Z',
billingBlocked: false,
billingBlockedReason: null,
blockedByOrgOwner: false,
upgradeWorkspaceId: 'workspace-1',
userRole: 'admin' as const,
}

describe('GET /api/organizations/[id]/billing-summary', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mockReadBillingSummary.mockResolvedValue(summary)
})

it('rejects an unauthenticated request before the protected read runs', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)

const response = await GET(createMockRequest('GET'), routeContext)

expect(response.status).toBe(401)
expect(mockReadBillingSummary).not.toHaveBeenCalled()
})

it('projects an authorization refusal without exposing billing data', async () => {
mockReadBillingSummary.mockRejectedValue(
new ForbiddenOperationError(
'ORGANIZATION_ADMIN_REQUIRED',
'Organization admin or owner authority is required to read billing information'
)
)

const response = await GET(createMockRequest('GET'), routeContext)

expect(response.status).toBe(403)
const body = await response.json()
expect(body).toEqual({
error: 'Organization admin or owner authority is required to read billing information',
})
expect(body).not.toHaveProperty('data')
})

it('maps the authenticated viewer and route organization into the semantic read', async () => {
const response = await GET(createMockRequest('GET'), routeContext)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ success: true, data: summary })
expect(mockReadBillingSummary).toHaveBeenCalledWith(
expect.objectContaining({
principal: {
kind: 'session',
userId: 'user-1',
sessionId: 'session-1',
},
input: { organizationId: 'organization-1' },
})
)
})
})
24 changes: 24 additions & 0 deletions apps/sim/app/api/organizations/[id]/billing-summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getOrganizationBillingSummaryContract } from '@/lib/api/contracts/organization'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { getOrganizationBillingSummary } from '@/lib/billing/application/organization-billing-summary/get-organization-billing-summary'
import { organizationBillingSummaryOperations } from '@/lib/billing/application/organization-billing-summary/operations'

export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
contract: getOrganizationBillingSummaryContract,
auth: internalSessionAuth,
operation: organizationBillingSummaryOperations.read,
rateLimit: internalRateLimits.none({
reason: 'Authenticated organization billing read, restricted to organization admins and owners',
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: getOrganizationBillingSummary,
present: (data) => ({ success: true, data }),
})
67 changes: 67 additions & 0 deletions apps/sim/app/api/users/me/profile/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockReadProfile } = vi.hoisted(() => ({
mockReadProfile: vi.fn(),
}))

vi.mock('@/lib/users/application/read-current-user', () => ({
getCurrentUserProfileUseCase: {
operation: { id: 'users.account.profile.read', principalKinds: ['session'] },
execute: mockReadProfile,
},
}))

import { GET } from '@/app/api/users/me/profile/route'

describe('GET /api/users/me/profile', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mockReadProfile.mockResolvedValue({
id: 'user-1',
name: 'User',
email: 'user@example.com',
image: null,
})
})

it('reads the authenticated account through the semantic use case', async () => {
const response = await GET(createMockRequest('GET'))

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
user: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
image: null,
},
})
expect(mockReadProfile).toHaveBeenCalledWith(
expect.objectContaining({
principal: {
kind: 'session',
userId: 'user-1',
sessionId: 'session-1',
},
input: {},
})
)
})

it('rejects an unauthenticated request before the use case runs', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)

const response = await GET(createMockRequest('GET'))

expect(response.status).toBe(401)
expect(mockReadProfile).not.toHaveBeenCalled()
})
})
Loading
Loading