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.
This commit is contained in:
Waleed
2026-07-03 13:20:59 -07:00
committed by GitHub
parent 4fe372b583
commit ca2a52ccf7
9 changed files with 434 additions and 106 deletions
@@ -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 })
)
})
})
+63 -103
View File
@@ -1,7 +1,7 @@
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'
@@ -9,108 +9,68 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('UsageLogsAPI')
const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 }
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
}
/**
* GET /api/users/me/usage-logs
* Get usage logs for the authenticated user
* 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 (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()
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
}
}
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 }
)
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),
])
)
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,
})
})
@@ -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'
@@ -640,6 +641,8 @@ export function Billing() {
</div>
</SettingsSection>
)}
{!subscription.isEnterprise && <CreditUsageSection />}
</SettingsPanel>
)
}
@@ -0,0 +1,126 @@
'use client'
import { Badge, ChipDropdown, type ChipDropdownOption, chipVariants, cn } from '@sim/emcn'
import { formatDateTime } from '@sim/utils/formatting'
import { useQueryStates } from 'nuqs'
import type { UsageLogEntry, UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user'
import { formatCreditsLabel } from '@/lib/billing/credits/conversion'
import {
billingParsers,
billingUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/components/billing/search-params'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { useUsageLogs } from '@/hooks/queries/usage-logs'
const PERIOD_OPTIONS: ReadonlyArray<ChipDropdownOption> = [
{ value: '1d', label: 'Today' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: 'all', label: 'All time' },
]
/**
* Humanized labels for `usage_log.source`. Avoids the internal "copilot" /
* "mothership" naming — the agent is always "Sim", the surface is "Chat".
*/
const SOURCE_LABELS: Record<UsageLogSource, string> = {
workflow: 'Workflow',
wand: 'Wand',
copilot: 'Chat',
'workspace-chat': 'Chat',
mcp_copilot: 'Chat (MCP)',
mothership_block: 'Agent block',
'knowledge-base': 'Knowledge Base',
'voice-input': 'Voice input',
enrichment: 'Enrichment',
}
interface UsageLogRowProps {
log: UsageLogEntry
}
function UsageLogRow({ log }: UsageLogRowProps) {
return (
<div className='flex items-center gap-2.5 rounded-lg p-2 text-left'>
<span className='w-[150px] flex-shrink-0 text-[12px] text-[var(--text-muted)]'>
{formatDateTime(new Date(log.createdAt))}
</span>
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
{log.description}
</span>
<Badge variant='gray-secondary' size='sm' className='flex-shrink-0'>
{SOURCE_LABELS[log.source]}
</Badge>
<span className='flex-shrink-0 text-[12px] text-[var(--text-muted)] tabular-nums'>
{formatCreditsLabel(log.creditCost)}
</span>
</div>
)
}
/**
* Exposes the credit-consuming usage events behind a user's billing period —
* the same underlying ledger the usage limit and cost breakdown are computed
* from — as a paginated, filterable list. Shown to every non-enterprise plan
* so builders can see exactly where their credits went.
*/
export function CreditUsageSection() {
const [{ period }, setFilters] = useQueryStates(billingParsers, billingUrlKeys)
const { data, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } = useUsageLogs(
{ period }
)
const logs = data?.pages.flatMap((page) => page.logs) ?? []
const totalCredits = data?.pages[0]?.summary.totalCredits ?? 0
return (
<SettingsSection
label='Credit usage'
action={
<ChipDropdown
options={PERIOD_OPTIONS}
value={period}
onChange={(value) => setFilters({ period: value as UsageLogPeriod })}
/>
}
>
<div className='-mx-2 flex flex-col gap-y-0.5'>
{isLoading ? (
<SettingsEmptyState variant='inline'>Loading usage…</SettingsEmptyState>
) : isError ? (
<SettingsEmptyState variant='inline'>Couldn't load credit usage.</SettingsEmptyState>
) : logs.length === 0 ? (
<SettingsEmptyState variant='inline'>No credit usage in this period.</SettingsEmptyState>
) : (
<>
<div className='flex items-center justify-between px-2 pb-2 text-small'>
<span className='text-[var(--text-muted)]'>Total</span>
<span className='text-[var(--text-body)] tabular-nums'>
{formatCreditsLabel(totalCredits)}
</span>
</div>
{logs.map((log) => (
<UsageLogRow key={log.id} log={log} />
))}
{hasNextPage && (
<button
type='button'
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
aria-label='Load more usage'
className={cn(
chipVariants({ fullWidth: true }),
'text-[var(--text-muted)] text-small'
)}
>
{isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
)}
</>
)}
</div>
</SettingsSection>
)
}
@@ -0,0 +1,21 @@
import { parseAsStringLiteral } from 'nuqs/server'
import { usageLogPeriodSchema } from '@/lib/api/contracts/user'
/**
* Co-located, typed URL query-param definitions for the Billing settings
* view.
*
* - `period` is the Credit usage section's time-window filter, sharing its
* literal values with {@link usageLogPeriodSchema} so the URL parser can
* never drift from the API contract it filters.
*/
export const billingParsers = {
period: parseAsStringLiteral(usageLogPeriodSchema.options).withDefault('30d'),
} as const
/** Filter view-state: clean URLs, no back-stack churn. */
export const billingUrlKeys = {
history: 'replace',
shallow: true,
clearOnDefault: true,
} as const
+47
View File
@@ -0,0 +1,47 @@
'use client'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
getUsageLogsContract,
type UsageLogPeriod,
type UsageLogsApiResponse,
} from '@/lib/api/contracts/user'
import { usageLogKeys } from '@/hooks/queries/utils/usage-log-keys'
const PAGE_SIZE = 25
async function fetchUsageLogs(
period: UsageLogPeriod,
cursor: string | undefined,
signal?: AbortSignal
): Promise<UsageLogsApiResponse> {
return requestJson(getUsageLogsContract, {
query: { period, limit: PAGE_SIZE, cursor },
signal,
})
}
interface UseUsageLogsOptions {
period: UsageLogPeriod
enabled?: boolean
}
/**
* Infinite-scrolls the authenticated user's credit-consuming usage events for
* the Billing settings "Credit usage" section, keyset-paginated by the
* backend's opaque `nextCursor`. Keeps the prior period's rows on screen
* while a newly selected period loads, since `period` is a variable key.
*/
export function useUsageLogs({ period, enabled = true }: UseUsageLogsOptions) {
return useInfiniteQuery({
queryKey: usageLogKeys.list(period),
queryFn: ({ pageParam, signal }) => fetchUsageLogs(period, pageParam, signal),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.pagination.hasMore ? lastPage.pagination.nextCursor : undefined,
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
@@ -0,0 +1,18 @@
/**
* React Query key factory for the credit usage log.
*
* Lives in this standalone (non-`'use client'`) module — like
* {@link file://./table-keys.ts} — so it can be imported from server
* components without pulling in the `'use client'`
* `@/hooks/queries/usage-logs` module, whose exports would otherwise
* resolve to client-reference stubs on the server.
*/
import type { UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user'
export const usageLogKeys = {
all: ['usage-logs'] as const,
lists: () => [...usageLogKeys.all, 'list'] as const,
list: (period: UsageLogPeriod, source?: UsageLogSource) =>
[...usageLogKeys.lists(), period, source ?? ''] as const,
}
+53 -2
View File
@@ -264,14 +264,65 @@ export type UnsubscribeActionResponse = ContractJsonResponse<typeof unsubscribeP
export type UnsubscribeBody = z.input<typeof unsubscribeBodySchema>
export type UnsubscribeType = NonNullable<UnsubscribeBody['type']>
export const usageLogSourceSchema = z.enum([
'workflow',
'wand',
'copilot',
'workspace-chat',
'mcp_copilot',
'mothership_block',
'knowledge-base',
'voice-input',
'enrichment',
])
export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all'])
export const usageLogsQuerySchema = z.object({
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
source: usageLogSourceSchema.optional(),
workspaceId: z.string().optional(),
period: z.enum(['1d', '7d', '30d', 'all']).optional().default('30d'),
period: usageLogPeriodSchema.optional().default('30d'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
cursor: z.string().optional(),
})
export const usageLogEntrySchema = z.object({
id: z.string(),
createdAt: z.string(),
source: usageLogSourceSchema,
description: z.string(),
/** Credit-denominated cost of this event (Sim's usage unit; 1,000 credits = $5). */
creditCost: z.number(),
})
export const usageLogsApiResponseSchema = z.object({
success: z.boolean(),
logs: z.array(usageLogEntrySchema),
summary: z.object({
totalCredits: z.number(),
bySourceCredits: z.record(z.string(), z.number()),
}),
pagination: z.object({
nextCursor: z.string().optional(),
hasMore: z.boolean(),
}),
})
export const getUsageLogsContract = defineRouteContract({
method: 'GET',
path: '/api/users/me/usage-logs',
query: usageLogsQuerySchema,
response: {
mode: 'json',
schema: usageLogsApiResponseSchema,
},
})
export type UsageLogSource = z.output<typeof usageLogSourceSchema>
export type UsageLogPeriod = z.output<typeof usageLogPeriodSchema>
export type UsageLogEntry = z.output<typeof usageLogEntrySchema>
export type UsageLogsApiResponse = z.output<typeof usageLogsApiResponseSchema>
export const subscriptionTransferParamsSchema = z.object({
id: z.string({ error: 'Subscription ID is required' }).min(1, 'Subscription ID is required'),
})
+13 -1
View File
@@ -21,6 +21,18 @@ export function creditsToDollars(credits: number): number {
return credits / CREDIT_MULTIPLIER
}
/**
* Pluralizes an already credit-denominated integer, e.g. `1234` -> `"1,234
* credits"`, `1` -> `"1 credit"`. Low-level building block for
* {@link formatCreditCost} — also useful directly for consumers that already
* hold precise integer credits (e.g. a server-converted `creditCost` value),
* which would otherwise double-convert by round-tripping back through
* dollars.
*/
export function formatCreditsLabel(credits: number): string {
return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}`
}
/**
* Single source of truth for rendering a dollar cost as a credit label.
*
@@ -50,7 +62,7 @@ export function formatCreditCost(
return opts?.emptyForZeroOrLess ? undefined : '0 credits'
}
return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}`
return formatCreditsLabel(credits)
}
/**