Skip to content

Commit ca2a52c

Browse files
authored
feat(billing): expose credit usage log in Billing settings (#5391)
* feat(billing): expose credit usage log in Billing settings Add a "Credit usage" section under Billing, below Invoices, showing a paginated, period-filterable list of individual credit-consuming events (model, tool, and fixed charges) for every plan except Enterprise. Wires the existing (previously unused) usage-logs backend to a proper contract, React Query hook, and emcn-styled UI: - getUsageLogsContract in contracts/user.ts, broadened the source enum to match the real usage_log schema - Rewrote the route to use parseRequest per the API boundary rules - useUsageLogs infinite-query hook, keyset-paginated - CreditUsageSection: period dropdown, total-credits summary, row list with source badges, "Load more" - Extracted formatCreditsLabel in conversion.ts as the shared formatter for already-converted integer credits * fix(billing): move usage-log key factory out of the 'use client' boundary Greptile P2: usageLogKeys lived in the 'use client' usage-logs.ts hook file — importing it from a server component (e.g. a future prefetch) would resolve to a client-reference stub and crash at build/SSR, the same class of bug that hit tables' key factory before. Extracted to hooks/queries/utils/usage-log-keys.ts, matching table-keys.ts and folder-keys.ts. Also renamed a shadowed `source` map param in the route to `sourceKey` for clarity. * chore(billing): dedupe the usage-log period literal type The '1d' | '7d' | '30d' | 'all' union was hand-typed in three places across usage-logs.ts and credit-usage-section.tsx. Extracted usageLogPeriodSchema in the contract and derived UsageLogPeriod from it, so the hook, the component, and the key factory all share one definition instead of risking drift. * improvement(billing): move credit usage period filter into the URL /cleanup pass (nuqs rule, react-query-best-practices, emcn review): - period was a plain useState, but it's exactly the kind of shareable list filter sim-url-state.md calls out for nuqs — migrated to billingParsers/useQueryStates so the selection deep-links, survives reload, and matches every sibling settings section (recently-deleted, inbox, teammates). Derives its literal values from usageLogPeriodSchema instead of a fourth copy of the same union. - Removed an unjustified showSelectedCheck={false} on the period ChipDropdown — the one other usage in the codebase is a one-shot action menu with no persistent selection; this is a real filter and should show the check like the default intends. - Added placeholderData: keepPreviousData to useUsageLogs — period is a variable query key, so without it switching periods flashed the loading empty-state instead of smoothly transitioning. Verified live: deep-link with ?period=7d pre-selects and loads correctly, switching periods updates the URL, and the selection survives a hard reload.
1 parent 4fe372b commit ca2a52c

9 files changed

Lines changed: 434 additions & 106 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetUserUsageLogs } = vi.hoisted(() => ({
8+
mockGetUserUsageLogs: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/billing/core/usage-log', () => ({
12+
getUserUsageLogs: mockGetUserUsageLogs,
13+
}))
14+
15+
import { GET } from '@/app/api/users/me/usage-logs/route'
16+
17+
describe('GET /api/users/me/usage-logs', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
21+
mockGetUserUsageLogs.mockResolvedValue({
22+
logs: [
23+
{
24+
id: 'log-1',
25+
createdAt: '2026-07-01T00:00:00.000Z',
26+
category: 'model',
27+
source: 'workflow',
28+
description: 'gpt-4o',
29+
cost: 0.5,
30+
},
31+
],
32+
summary: { totalCost: 0.5, bySource: { workflow: 0.5 } },
33+
pagination: { hasMore: false },
34+
})
35+
})
36+
37+
it('returns 401 when unauthenticated', async () => {
38+
authMockFns.mockGetSession.mockResolvedValue(null)
39+
40+
const response = await GET(createMockRequest('GET'))
41+
42+
expect(response.status).toBe(401)
43+
})
44+
45+
it('converts dollar costs to credits in the logs and summary', async () => {
46+
const response = await GET(createMockRequest('GET'))
47+
const body = await response.json()
48+
49+
expect(body.logs).toEqual([
50+
{
51+
id: 'log-1',
52+
createdAt: '2026-07-01T00:00:00.000Z',
53+
source: 'workflow',
54+
description: 'gpt-4o',
55+
creditCost: 100,
56+
},
57+
])
58+
expect(body.summary).toEqual({
59+
totalCredits: 100,
60+
bySourceCredits: { workflow: 100 },
61+
})
62+
})
63+
64+
it('rejects an invalid period', async () => {
65+
const response = await GET(
66+
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=1y')
67+
)
68+
69+
expect(response.status).toBe(400)
70+
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
71+
})
72+
73+
it('resolves the start date from the period filter', async () => {
74+
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=7d'))
75+
76+
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
77+
'user-1',
78+
expect.objectContaining({ startDate: expect.any(Date) })
79+
)
80+
})
81+
82+
it('omits the start date for the "all" period', async () => {
83+
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=all'))
84+
85+
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
86+
'user-1',
87+
expect.objectContaining({ startDate: undefined })
88+
)
89+
})
90+
})
Lines changed: 63 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,76 @@
11
import { createLogger } from '@sim/logger'
2-
import { toError } from '@sim/utils/errors'
32
import { type NextRequest, NextResponse } from 'next/server'
4-
import { usageLogsQuerySchema } from '@/lib/api/contracts/user'
3+
import { getUsageLogsContract } from '@/lib/api/contracts/user'
4+
import { parseRequest } from '@/lib/api/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
77
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99

1010
const logger = createLogger('UsageLogsAPI')
1111

12-
/**
13-
* GET /api/users/me/usage-logs
14-
* Get usage logs for the authenticated user
15-
*/
16-
export const GET = withRouteHandler(async (req: NextRequest) => {
17-
try {
18-
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
19-
20-
if (!auth.success || !auth.userId) {
21-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
22-
}
23-
24-
const userId = auth.userId
25-
26-
const { searchParams } = new URL(req.url)
27-
const queryParams = {
28-
source: searchParams.get('source') || undefined,
29-
workspaceId: searchParams.get('workspaceId') || undefined,
30-
period: searchParams.get('period') || '30d',
31-
limit: searchParams.get('limit') || '50',
32-
cursor: searchParams.get('cursor') || undefined,
33-
}
34-
35-
const validation = usageLogsQuerySchema.safeParse(queryParams)
36-
37-
if (!validation.success) {
38-
return NextResponse.json(
39-
{
40-
error: 'Invalid query parameters',
41-
details: validation.error.issues,
42-
},
43-
{ status: 400 }
44-
)
45-
}
46-
47-
const { source, workspaceId, period, limit, cursor } = validation.data
48-
49-
let startDate: Date | undefined
50-
const endDate = new Date()
12+
const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 }
5113

52-
if (period !== 'all') {
53-
startDate = new Date()
54-
switch (period) {
55-
case '1d':
56-
startDate.setDate(startDate.getDate() - 1)
57-
break
58-
case '7d':
59-
startDate.setDate(startDate.getDate() - 7)
60-
break
61-
case '30d':
62-
startDate.setDate(startDate.getDate() - 30)
63-
break
64-
}
65-
}
14+
function resolveStartDate(period: '1d' | '7d' | '30d' | 'all'): Date | undefined {
15+
if (period === 'all') return undefined
16+
const startDate = new Date()
17+
startDate.setDate(startDate.getDate() - PERIOD_TO_DAYS[period])
18+
return startDate
19+
}
6620

67-
const result = await getUserUsageLogs(userId, {
68-
source: source as UsageLogSource | undefined,
69-
workspaceId,
70-
startDate,
71-
endDate,
72-
limit,
73-
cursor,
74-
})
75-
76-
const logsWithCredits = result.logs.map((log) => ({
77-
...log,
78-
creditCost: dollarsToCredits(log.cost),
79-
}))
80-
81-
const bySourceCredits: Record<string, number> = {}
82-
for (const [src, cost] of Object.entries(result.summary.bySource)) {
83-
bySourceCredits[src] = dollarsToCredits(cost)
84-
}
85-
86-
logger.debug('Retrieved usage logs', {
87-
userId,
88-
source,
89-
period,
90-
logCount: result.logs.length,
91-
hasMore: result.pagination.hasMore,
92-
})
93-
94-
return NextResponse.json({
95-
success: true,
96-
logs: logsWithCredits,
97-
summary: {
98-
...result.summary,
99-
totalCostCredits: dollarsToCredits(result.summary.totalCost),
100-
bySourceCredits,
101-
},
102-
pagination: result.pagination,
103-
})
104-
} catch (error) {
105-
logger.error('Failed to get usage logs', {
106-
error: toError(error).message,
107-
})
108-
109-
return NextResponse.json(
110-
{
111-
error: 'Failed to retrieve usage logs',
112-
},
113-
{ status: 500 }
114-
)
21+
/**
22+
* Lists the authenticated user's credit-consuming usage events (model, tool,
23+
* and fixed charges), converted to credits for display in Billing settings.
24+
*/
25+
export const GET = withRouteHandler(async (request: NextRequest) => {
26+
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
27+
if (!auth.success || !auth.userId) {
28+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
11529
}
30+
31+
const parsed = await parseRequest(getUsageLogsContract, request, {})
32+
if (!parsed.success) return parsed.response
33+
const { source, workspaceId, period, limit, cursor } = parsed.data.query
34+
35+
const result = await getUserUsageLogs(auth.userId, {
36+
source: source as UsageLogSource | undefined,
37+
workspaceId,
38+
startDate: resolveStartDate(period),
39+
endDate: new Date(),
40+
limit,
41+
cursor,
42+
})
43+
44+
const logs = result.logs.map((log) => ({
45+
id: log.id,
46+
createdAt: log.createdAt,
47+
source: log.source,
48+
description: log.description,
49+
creditCost: dollarsToCredits(log.cost),
50+
}))
51+
52+
const bySourceCredits = Object.fromEntries(
53+
Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [
54+
sourceKey,
55+
dollarsToCredits(cost),
56+
])
57+
)
58+
59+
logger.debug('Retrieved usage logs', {
60+
userId: auth.userId,
61+
source,
62+
period,
63+
logCount: logs.length,
64+
hasMore: result.pagination.hasMore,
65+
})
66+
67+
return NextResponse.json({
68+
success: true,
69+
logs,
70+
summary: {
71+
totalCredits: dollarsToCredits(result.summary.totalCost),
72+
bySourceCredits,
73+
},
74+
pagination: result.pagination,
75+
})
11676
})

apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from '@/lib/billing/subscriptions/utils'
4343
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
4444
import { getBaseUrl } from '@/lib/core/utils/urls'
45+
import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section'
4546
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
4647
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions'
4748
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
@@ -640,6 +641,8 @@ export function Billing() {
640641
</div>
641642
</SettingsSection>
642643
)}
644+
645+
{!subscription.isEnterprise && <CreditUsageSection />}
643646
</SettingsPanel>
644647
)
645648
}

0 commit comments

Comments
 (0)