From a9b649d2795b23cf95b45658b2054afacebb33cf Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 12:44:51 -0700 Subject: [PATCH 1/4] 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 --- .../app/api/users/me/usage-logs/route.test.ts | 90 ++++++++++ apps/sim/app/api/users/me/usage-logs/route.ts | 164 +++++++----------- .../settings/components/billing/billing.tsx | 3 + .../credit-usage-section.tsx | 125 +++++++++++++ apps/sim/hooks/queries/usage-logs.ts | 51 ++++++ apps/sim/lib/api/contracts/user.ts | 50 +++++- apps/sim/lib/billing/credits/conversion.ts | 14 +- 7 files changed, 393 insertions(+), 104 deletions(-) create mode 100644 apps/sim/app/api/users/me/usage-logs/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx create mode 100644 apps/sim/hooks/queries/usage-logs.ts 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..e977ffd26a3 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) - } +/** + * 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 }) + } - logger.debug('Retrieved usage logs', { - userId, + 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(([source, cost]) => [ 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 } - ) - } + 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..8bad4d97da8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useState } from 'react' +import { Badge, ChipDropdown, type ChipDropdownOption, chipVariants, cn } from '@sim/emcn' +import { formatDateTime } from '@sim/utils/formatting' +import type { UsageLogEntry, UsageLogSource } from '@/lib/api/contracts/user' +import { formatCreditsLabel } from '@/lib/billing/credits/conversion' +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' + +type Period = '1d' | '7d' | '30d' | 'all' + +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, setPeriod] = useState('30d') + + 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 ( + setPeriod(value as Period)} + showSelectedCheck={false} + /> + } + > +
+ {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/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts new file mode 100644 index 00000000000..d0a50f56dc5 --- /dev/null +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -0,0 +1,51 @@ +'use client' + +import { useInfiniteQuery } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + getUsageLogsContract, + type UsageLogSource, + type UsageLogsApiResponse, +} from '@/lib/api/contracts/user' + +export const usageLogKeys = { + all: ['usage-logs'] as const, + lists: () => [...usageLogKeys.all, 'list'] as const, + list: (period: string, source?: UsageLogSource) => + [...usageLogKeys.lists(), period, source ?? ''] as const, +} + +const PAGE_SIZE = 25 + +async function fetchUsageLogs( + period: '1d' | '7d' | '30d' | 'all', + cursor: string | undefined, + signal?: AbortSignal +): Promise { + return requestJson(getUsageLogsContract, { + query: { period, limit: PAGE_SIZE, cursor }, + signal, + }) +} + +interface UseUsageLogsOptions { + period: '1d' | '7d' | '30d' | 'all' + 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`. + */ +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, + }) +} diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 030c851551b..5eaa01e3316 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -264,14 +264,62 @@ 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 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'), 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 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) } /** From 89fe7e773946664d0424a703a4fd333008f59e40 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 12:52:17 -0700 Subject: [PATCH 2/4] fix(billing): move usage-log key factory out of the 'use client' boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/sim/app/api/users/me/usage-logs/route.ts | 4 ++-- apps/sim/hooks/queries/usage-logs.ts | 14 ++------------ apps/sim/hooks/queries/utils/usage-log-keys.ts | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/usage-log-keys.ts 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 e977ffd26a3..6502d545bbe 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -50,8 +50,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { })) const bySourceCredits = Object.fromEntries( - Object.entries(result.summary.bySource).map(([source, cost]) => [ - source, + Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [ + sourceKey, dollarsToCredits(cost), ]) ) diff --git a/apps/sim/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts index d0a50f56dc5..145d3ece280 100644 --- a/apps/sim/hooks/queries/usage-logs.ts +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -2,18 +2,8 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' -import { - getUsageLogsContract, - type UsageLogSource, - type UsageLogsApiResponse, -} from '@/lib/api/contracts/user' - -export const usageLogKeys = { - all: ['usage-logs'] as const, - lists: () => [...usageLogKeys.all, 'list'] as const, - list: (period: string, source?: UsageLogSource) => - [...usageLogKeys.lists(), period, source ?? ''] as const, -} +import { getUsageLogsContract, type UsageLogsApiResponse } from '@/lib/api/contracts/user' +import { usageLogKeys } from '@/hooks/queries/utils/usage-log-keys' const PAGE_SIZE = 25 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..45df1802139 --- /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 { UsageLogSource } from '@/lib/api/contracts/user' + +export const usageLogKeys = { + all: ['usage-logs'] as const, + lists: () => [...usageLogKeys.all, 'list'] as const, + list: (period: string, source?: UsageLogSource) => + [...usageLogKeys.lists(), period, source ?? ''] as const, +} From 9d6dd831171324bf8ebb79bf8025af6a6b2e25ae Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 13:02:29 -0700 Subject: [PATCH 3/4] 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. --- .../credit-usage-section/credit-usage-section.tsx | 8 +++----- apps/sim/hooks/queries/usage-logs.ts | 10 +++++++--- apps/sim/hooks/queries/utils/usage-log-keys.ts | 4 ++-- apps/sim/lib/api/contracts/user.ts | 5 ++++- 4 files changed, 16 insertions(+), 11 deletions(-) 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 index 8bad4d97da8..3564e7e2bfc 100644 --- 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 @@ -3,14 +3,12 @@ import { useState } from 'react' import { Badge, ChipDropdown, type ChipDropdownOption, chipVariants, cn } from '@sim/emcn' import { formatDateTime } from '@sim/utils/formatting' -import type { UsageLogEntry, UsageLogSource } from '@/lib/api/contracts/user' +import type { UsageLogEntry, UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user' import { formatCreditsLabel } from '@/lib/billing/credits/conversion' 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' -type Period = '1d' | '7d' | '30d' | 'all' - const PERIOD_OPTIONS: ReadonlyArray = [ { value: '1d', label: 'Today' }, { value: '7d', label: 'Last 7 days' }, @@ -64,7 +62,7 @@ function UsageLogRow({ log }: UsageLogRowProps) { * so builders can see exactly where their credits went. */ export function CreditUsageSection() { - const [period, setPeriod] = useState('30d') + const [period, setPeriod] = useState('30d') const { data, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } = useUsageLogs( { period } @@ -80,7 +78,7 @@ export function CreditUsageSection() { setPeriod(value as Period)} + onChange={(value) => setPeriod(value as UsageLogPeriod)} showSelectedCheck={false} /> } diff --git a/apps/sim/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts index 145d3ece280..bce0ba22718 100644 --- a/apps/sim/hooks/queries/usage-logs.ts +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -2,13 +2,17 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' -import { getUsageLogsContract, type UsageLogsApiResponse } from '@/lib/api/contracts/user' +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: '1d' | '7d' | '30d' | 'all', + period: UsageLogPeriod, cursor: string | undefined, signal?: AbortSignal ): Promise { @@ -19,7 +23,7 @@ async function fetchUsageLogs( } interface UseUsageLogsOptions { - period: '1d' | '7d' | '30d' | 'all' + period: UsageLogPeriod enabled?: boolean } diff --git a/apps/sim/hooks/queries/utils/usage-log-keys.ts b/apps/sim/hooks/queries/utils/usage-log-keys.ts index 45df1802139..2911f3ca69d 100644 --- a/apps/sim/hooks/queries/utils/usage-log-keys.ts +++ b/apps/sim/hooks/queries/utils/usage-log-keys.ts @@ -8,11 +8,11 @@ * resolve to client-reference stubs on the server. */ -import type { UsageLogSource } from '@/lib/api/contracts/user' +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: string, source?: UsageLogSource) => + 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 5eaa01e3316..3d4c0d79532 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -276,10 +276,12 @@ export const usageLogSourceSchema = z.enum([ 'enrichment', ]) +export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all']) + export const usageLogsQuerySchema = z.object({ 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(), }) @@ -317,6 +319,7 @@ export const getUsageLogsContract = defineRouteContract({ }) export type UsageLogSource = z.output +export type UsageLogPeriod = z.output export type UsageLogEntry = z.output export type UsageLogsApiResponse = z.output From 73679b175688cff388248db0bbf9b2ac49340fb3 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 13:13:26 -0700 Subject: [PATCH 4/4] improvement(billing): move credit usage period filter into the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- .../credit-usage-section.tsx | 11 ++++++---- .../components/billing/search-params.ts | 21 +++++++++++++++++++ apps/sim/hooks/queries/usage-logs.ts | 6 ++++-- 3 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/billing/search-params.ts 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 index 3564e7e2bfc..68aff42692b 100644 --- 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 @@ -1,10 +1,14 @@ 'use client' -import { useState } from 'react' 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' @@ -62,7 +66,7 @@ function UsageLogRow({ log }: UsageLogRowProps) { * so builders can see exactly where their credits went. */ export function CreditUsageSection() { - const [period, setPeriod] = useState('30d') + const [{ period }, setFilters] = useQueryStates(billingParsers, billingUrlKeys) const { data, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } = useUsageLogs( { period } @@ -78,8 +82,7 @@ export function CreditUsageSection() { setPeriod(value as UsageLogPeriod)} - showSelectedCheck={false} + onChange={(value) => setFilters({ period: value as UsageLogPeriod })} /> } > 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 index bce0ba22718..aabd57c6cdb 100644 --- a/apps/sim/hooks/queries/usage-logs.ts +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -1,6 +1,6 @@ 'use client' -import { useInfiniteQuery } from '@tanstack/react-query' +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { getUsageLogsContract, @@ -30,7 +30,8 @@ interface UseUsageLogsOptions { /** * 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`. + * 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({ @@ -41,5 +42,6 @@ export function useUsageLogs({ period, enabled = true }: UseUsageLogsOptions) { lastPage.pagination.hasMore ? lastPage.pagination.nextCursor : undefined, enabled, staleTime: 30 * 1000, + placeholderData: keepPreviousData, }) }