diff --git a/apps/sim/app/api/users/me/usage-logs/route.test.ts b/apps/sim/app/api/users/me/usage-logs/route.test.ts new file mode 100644 index 00000000000..13c916d4cb7 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts @@ -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 }) + ) + }) +}) diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index b5de52d9eb0..6502d545bbe 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -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') -/** - * 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 = {} - 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), + ]) + ) + + 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, + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index d4ffc001032..466ea81e4f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -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() { )} + + {!subscription.isEnterprise && } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx new file mode 100644 index 00000000000..68aff42692b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx @@ -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 = [ + { 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 = { + 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 ( +
+ + {formatDateTime(new Date(log.createdAt))} + + + {log.description} + + + {SOURCE_LABELS[log.source]} + + + {formatCreditsLabel(log.creditCost)} + +
+ ) +} + +/** + * 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 ( + setFilters({ period: value as UsageLogPeriod })} + /> + } + > +
+ {isLoading ? ( + Loading usage… + ) : isError ? ( + Couldn't load credit usage. + ) : logs.length === 0 ? ( + No credit usage in this period. + ) : ( + <> +
+ Total + + {formatCreditsLabel(totalCredits)} + +
+ {logs.map((log) => ( + + ))} + {hasNextPage && ( + + )} + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/search-params.ts new file mode 100644 index 00000000000..54e2eb2ce04 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/search-params.ts @@ -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 diff --git a/apps/sim/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts new file mode 100644 index 00000000000..aabd57c6cdb --- /dev/null +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -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 { + 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, + }) +} diff --git a/apps/sim/hooks/queries/utils/usage-log-keys.ts b/apps/sim/hooks/queries/utils/usage-log-keys.ts new file mode 100644 index 00000000000..2911f3ca69d --- /dev/null +++ b/apps/sim/hooks/queries/utils/usage-log-keys.ts @@ -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, +} diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 030c851551b..3d4c0d79532 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -264,14 +264,65 @@ export type UnsubscribeActionResponse = ContractJsonResponse export type UnsubscribeType = NonNullable +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 +export type UsageLogPeriod = z.output +export type UsageLogEntry = z.output +export type UsageLogsApiResponse = z.output + export const subscriptionTransferParamsSchema = z.object({ id: z.string({ error: 'Subscription ID is required' }).min(1, 'Subscription ID is required'), }) diff --git a/apps/sim/lib/billing/credits/conversion.ts b/apps/sim/lib/billing/credits/conversion.ts index e11c716282c..1a24d40ebd4 100644 --- a/apps/sim/lib/billing/credits/conversion.ts +++ b/apps/sim/lib/billing/credits/conversion.ts @@ -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) } /**