-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(billing): expose credit usage log in Billing settings #5391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+434
−106
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a9b649d
feat(billing): expose credit usage log in Billing settings
waleedlatif1 89fe7e7
fix(billing): move usage-log key factory out of the 'use client' boun…
waleedlatif1 9d6dd83
chore(billing): dedupe the usage-log period literal type
waleedlatif1 73679b1
improvement(billing): move credit usage period filter into the URL
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }) | ||
| ) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| })) | ||
|
|
||
| const bySourceCredits = Object.fromEntries( | ||
| Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [ | ||
| sourceKey, | ||
| dollarsToCredits(cost), | ||
| ]) | ||
| ) | ||
|
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, | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.