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
90 changes: 90 additions & 0 deletions apps/sim/app/api/users/me/usage-logs/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@/lib/billing/core/usage-log', () => ({
getUserUsageLogs: mockGetUserUsageLogs,
}))

import { GET } from '@/app/api/users/me/usage-logs/route'

describe('GET /api/users/me/usage-logs', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockGetUserUsageLogs.mockResolvedValue({
logs: [
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
category: 'model',
source: 'workflow',
description: 'gpt-4o',
cost: 0.5,
},
],
summary: { totalCost: 0.5, bySource: { workflow: 0.5 } },
pagination: { hasMore: false },
})
})

it('returns 401 when unauthenticated', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)

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

expect(response.status).toBe(401)
})

it('converts dollar costs to credits in the logs and summary', async () => {
const response = await GET(createMockRequest('GET'))
const body = await response.json()

expect(body.logs).toEqual([
{
id: 'log-1',
createdAt: '2026-07-01T00:00:00.000Z',
source: 'workflow',
description: 'gpt-4o',
creditCost: 100,
},
])
expect(body.summary).toEqual({
totalCredits: 100,
bySourceCredits: { workflow: 100 },
})
})

it('rejects an invalid period', async () => {
const response = await GET(
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=1y')
)

expect(response.status).toBe(400)
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
})

it('resolves the start date from the period filter', async () => {
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=7d'))

expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ startDate: expect.any(Date) })
)
})

it('omits the start date for the "all" period', async () => {
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=all'))

expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ startDate: undefined })
)
})
})
166 changes: 63 additions & 103 deletions apps/sim/app/api/users/me/usage-logs/route.ts
Original file line number Diff line number Diff line change
@@ -1,116 +1,76 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { usageLogsQuerySchema } from '@/lib/api/contracts/user'
import { getUsageLogsContract } from '@/lib/api/contracts/user'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('UsageLogsAPI')

/**
* GET /api/users/me/usage-logs
* Get usage logs for the authenticated user
*/
export const GET = withRouteHandler(async (req: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })

if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const userId = auth.userId

const { searchParams } = new URL(req.url)
const queryParams = {
source: searchParams.get('source') || undefined,
workspaceId: searchParams.get('workspaceId') || undefined,
period: searchParams.get('period') || '30d',
limit: searchParams.get('limit') || '50',
cursor: searchParams.get('cursor') || undefined,
}

const validation = usageLogsQuerySchema.safeParse(queryParams)

if (!validation.success) {
return NextResponse.json(
{
error: 'Invalid query parameters',
details: validation.error.issues,
},
{ status: 400 }
)
}

const { source, workspaceId, period, limit, cursor } = validation.data

let startDate: Date | undefined
const endDate = new Date()
const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 }

if (period !== 'all') {
startDate = new Date()
switch (period) {
case '1d':
startDate.setDate(startDate.getDate() - 1)
break
case '7d':
startDate.setDate(startDate.getDate() - 7)
break
case '30d':
startDate.setDate(startDate.getDate() - 30)
break
}
}
function resolveStartDate(period: '1d' | '7d' | '30d' | 'all'): Date | undefined {
if (period === 'all') return undefined
const startDate = new Date()
startDate.setDate(startDate.getDate() - PERIOD_TO_DAYS[period])
return startDate
}

const result = await getUserUsageLogs(userId, {
source: source as UsageLogSource | undefined,
workspaceId,
startDate,
endDate,
limit,
cursor,
})

const logsWithCredits = result.logs.map((log) => ({
...log,
creditCost: dollarsToCredits(log.cost),
}))

const bySourceCredits: Record<string, number> = {}
for (const [src, cost] of Object.entries(result.summary.bySource)) {
bySourceCredits[src] = dollarsToCredits(cost)
}

logger.debug('Retrieved usage logs', {
userId,
source,
period,
logCount: result.logs.length,
hasMore: result.pagination.hasMore,
})

return NextResponse.json({
success: true,
logs: logsWithCredits,
summary: {
...result.summary,
totalCostCredits: dollarsToCredits(result.summary.totalCost),
bySourceCredits,
},
pagination: result.pagination,
})
} catch (error) {
logger.error('Failed to get usage logs', {
error: toError(error).message,
})

return NextResponse.json(
{
error: 'Failed to retrieve usage logs',
},
{ status: 500 }
)
/**
* Lists the authenticated user's credit-consuming usage events (model, tool,
* and fixed charges), converted to credits for display in Billing settings.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(getUsageLogsContract, request, {})
if (!parsed.success) return parsed.response
const { source, workspaceId, period, limit, cursor } = parsed.data.query

const result = await getUserUsageLogs(auth.userId, {
source: source as UsageLogSource | undefined,
workspaceId,
startDate: resolveStartDate(period),
endDate: new Date(),
limit,
cursor,
})

const logs = result.logs.map((log) => ({
id: log.id,
createdAt: log.createdAt,
source: log.source,
description: log.description,
creditCost: dollarsToCredits(log.cost),
Comment thread
waleedlatif1 marked this conversation as resolved.
}))

const bySourceCredits = Object.fromEntries(
Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [
sourceKey,
dollarsToCredits(cost),
])
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

logger.debug('Retrieved usage logs', {
userId: auth.userId,
source,
period,
logCount: logs.length,
hasMore: result.pagination.hasMore,
})

return NextResponse.json({
success: true,
logs,
summary: {
totalCredits: dollarsToCredits(result.summary.totalCost),
bySourceCredits,
},
pagination: result.pagination,
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from '@/lib/billing/subscriptions/utils'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section'
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
Expand Down Expand Up @@ -640,6 +641,8 @@ export function Billing() {
</div>
</SettingsSection>
)}

{!subscription.isEnterprise && <CreditUsageSection />}
</SettingsPanel>
)
}
Loading
Loading