diff --git a/apps/sim/app/api/audit-logs/export/route.test.ts b/apps/sim/app/api/audit-logs/export/route.test.ts new file mode 100644 index 00000000000..94072ebd42f --- /dev/null +++ b/apps/sim/app/api/audit-logs/export/route.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockValidateEnterpriseAuditAccess, + mockBuildOrgScopeCondition, + mockGetOrgWorkspaceIds, + mockQueryAuditLogs, + mockBuildFilterConditions, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockValidateEnterpriseAuditAccess: vi.fn(), + mockBuildOrgScopeCondition: vi.fn(), + mockGetOrgWorkspaceIds: vi.fn(), + mockQueryAuditLogs: vi.fn(), + mockBuildFilterConditions: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@/app/api/v1/audit-logs/auth', () => ({ + validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, +})) + +vi.mock('@/app/api/v1/audit-logs/query', () => ({ + buildFilterConditions: mockBuildFilterConditions, + buildOrgScopeCondition: mockBuildOrgScopeCondition, + getOrgWorkspaceIds: mockGetOrgWorkspaceIds, + queryAuditLogs: mockQueryAuditLogs, +})) + +import { GET } from '@/app/api/audit-logs/export/route' + +const ORG_ID = 'org-1' +const MEMBER_IDS = ['admin-1'] +const SCOPE_SENTINEL = { type: 'org-scope-sentinel' } + +function makeRequest(query = '') { + return createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/audit-logs/export${query}` + ) +} + +function auditLog(overrides: Partial> = {}) { + return { + id: 'log-1', + workspaceId: null, + actorId: 'admin-1', + actorName: 'Ada Lovelace', + actorEmail: 'ada@example.com', + action: 'workflow.created', + resourceType: 'workflow', + resourceId: 'wf-1', + resourceName: 'My workflow', + description: null, + metadata: null, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + ...overrides, + } +} + +describe('GET /api/audit-logs/export', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mockValidateEnterpriseAuditAccess.mockResolvedValue({ + success: true, + context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS }, + }) + mockGetOrgWorkspaceIds.mockResolvedValue([]) + mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL) + mockBuildFilterConditions.mockReturnValue([]) + mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined }) + }) + + it('returns 401 when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(401) + }) + + it('returns the enterprise-access-check response when access is denied', async () => { + mockValidateEnterpriseAuditAccess.mockResolvedValue({ + success: false, + response: new Response( + JSON.stringify({ error: 'Organization admin or owner role required' }), + { + status: 403, + } + ), + }) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(403) + expect(mockQueryAuditLogs).not.toHaveBeenCalled() + }) + + it('returns a CSV with the header row and one line per log', async () => { + mockQueryAuditLogs.mockResolvedValueOnce({ data: [auditLog()], nextCursor: undefined }) + + const response = await GET(makeRequest()) + const csv = await response.text() + const [header, row] = csv.split('\n') + + expect(response.headers.get('Content-Type')).toBe('text/csv; charset=utf-8') + expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=') + expect(response.headers.get('X-Export-Truncated')).toBe('0') + expect(header).toBe('Date,Action,Resource Type,Resource Name,Actor,Description') + expect(row).toBe( + '2026-07-01T00:00:00.000Z,workflow.created,workflow,My workflow,ada@example.com,' + ) + }) + + it('falls back to actorName, then "System", when actorEmail is absent', async () => { + mockQueryAuditLogs.mockResolvedValueOnce({ + data: [auditLog({ actorEmail: null, actorName: 'Ada Lovelace' })], + nextCursor: undefined, + }) + + const response = await GET(makeRequest()) + const csv = await response.text() + + expect(csv).toContain('Ada Lovelace') + }) + + it('paginates through queryAuditLogs until there is no nextCursor', async () => { + mockQueryAuditLogs + .mockResolvedValueOnce({ + data: [auditLog({ id: 'log-1' })], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + data: [auditLog({ id: 'log-2' })], + nextCursor: undefined, + }) + + const response = await GET(makeRequest()) + const csv = await response.text() + + expect(mockQueryAuditLogs).toHaveBeenCalledTimes(2) + expect(mockQueryAuditLogs).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.any(Number), + 'cursor-1' + ) + expect(csv.split('\n')).toHaveLength(3) + }) + + it('rejects an actorId that is not a current org member', async () => { + const response = await GET(makeRequest('?actorId=outsider-1')) + + expect(response.status).toBe(400) + expect(mockQueryAuditLogs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/audit-logs/export/route.ts b/apps/sim/app/api/audit-logs/export/route.ts new file mode 100644 index 00000000000..b0238a0433c --- /dev/null +++ b/apps/sim/app/api/audit-logs/export/route.ts @@ -0,0 +1,151 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { exportAuditLogsContract } from '@/lib/api/contracts/audit-logs' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' +import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + getOrgWorkspaceIds, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' + +const logger = createLogger('AuditLogsExportAPI') + +/** + * Circuit breaker, not a UX boundary — an organization's audit trail can + * genuinely grow large over time, unlike a single user's credit ledger, so + * this is sized for "a reasonable audit review window" rather than "should + * never happen." Hitting it truncates (signaled via X-Export-Truncated), it + * doesn't error. + */ +const EXPORT_SAFETY_CAP = 10000 +const EXPORT_PAGE_SIZE = 1000 + +const CSV_HEADER = toCsvRow([ + 'Date', + 'Action', + 'Resource Type', + 'Resource Name', + 'Actor', + 'Description', +]) + +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const authResult = await validateEnterpriseAuditAccess(session.user.id) + if (!authResult.success) { + return authResult.response + } + + const { organizationId, orgMemberIds } = authResult.context + + const parsed = await parseRequest( + exportAuditLogsContract, + request, + {}, + { + validationErrorResponse: (error) => + NextResponse.json( + { error: getValidationErrorMessage(error, 'Invalid query parameters') }, + { status: 400 } + ), + } + ) + if (!parsed.success) return parsed.response + + const { search, action, resourceType, actorId, startDate, endDate, includeDeparted } = + parsed.data.query + + if (actorId && !orgMemberIds.includes(actorId)) { + return NextResponse.json( + { error: 'actorId is not a member of your organization' }, + { status: 400 } + ) + } + + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted, + }) + const filterConditions = buildFilterConditions({ + action, + resourceType, + actorId, + search, + startDate, + endDate, + }) + const conditions = [scopeCondition, ...filterConditions] + + const rows: ReturnType[] = [] + let cursor: string | undefined + let truncated = false + while (rows.length < EXPORT_SAFETY_CAP) { + const page = await queryAuditLogs( + conditions, + Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length), + cursor + ) + rows.push(...page.data.map(formatAuditLogEntry)) + if (!page.nextCursor) break + truncated = rows.length >= EXPORT_SAFETY_CAP + cursor = page.nextCursor + } + + if (truncated) { + logger.warn('Audit log export truncated at safety cap', { + userId: session.user.id, + organizationId, + cap: EXPORT_SAFETY_CAP, + }) + } + + const csvLines = rows.map((log) => + toCsvRow([ + formatCsvValue(log.createdAt), + formatCsvValue(log.action), + formatCsvValue(log.resourceType), + formatCsvValue(log.resourceName), + formatCsvValue(log.actorEmail || log.actorName || 'System'), + formatCsvValue(log.description), + ]) + ) + + const csv = [CSV_HEADER, ...csvLines].join('\n') + const filename = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv` + + logger.info('Exported audit logs', { + userId: session.user.id, + organizationId, + rowCount: rows.length, + }) + + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-cache', + 'X-Export-Truncated': truncated ? '1' : '0', + }, + }) + } catch (error: unknown) { + const message = getErrorMessage(error, 'Unknown error') + logger.error('Audit logs export error', { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.test.ts b/apps/sim/app/api/users/me/usage-logs/export/route.test.ts new file mode 100644 index 00000000000..ec82eec301f --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/export/route.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apportionCredits } from '@/lib/billing/credits/conversion' + +const { mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ + mockGetUserUsageLogs: vi.fn(), + mockGetUsageCreditsByLogId: vi.fn(), +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getUserUsageLogs: mockGetUserUsageLogs, + getUsageCreditsByLogId: mockGetUsageCreditsByLogId, +})) + +import { GET } from '@/app/api/users/me/usage-logs/export/route' + +describe('GET /api/users/me/usage-logs/export', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetUsageCreditsByLogId.mockResolvedValue({}) + }) + + it('returns 401 when unauthenticated', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(401) + }) + + it('returns a CSV with the header row and one line per log', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'copilot', + description: 'claude-opus-4.8', + cost: 0.5, + }, + ], + summary: { totalCost: 0.5, bySource: { copilot: 0.5 } }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue(apportionCredits([{ key: 'log-1', dollars: 0.5 }])) + + const response = await GET(createMockRequest('GET')) + const csv = await response.text() + const [header, row] = csv.split('\n') + + expect(response.headers.get('Content-Type')).toBe('text/csv; charset=utf-8') + expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=') + expect(response.headers.get('X-Export-Truncated')).toBe('0') + expect(header).toBe('Date,Type,Credits') + expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100') + }) + + it('sets X-Export-Truncated when the safety cap is hit with more data remaining', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: Array.from({ length: 50000 }, (_, i) => ({ + id: `log-${i}`, + createdAt: '2026-07-01T00:00:00.000Z', + source: 'copilot', + cost: 0.1, + })), + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-49999' }, + }) + + const response = await GET(createMockRequest('GET')) + + expect(response.headers.get('X-Export-Truncated')).toBe('1') + }) + + it('does not request the summary aggregate — the export never reads it', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: [], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + + await GET(createMockRequest('GET')) + + expect(mockGetUserUsageLogs).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ includeSummary: false }) + ) + }) + + it('names the specific workflow for workflow-sourced rows', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'fixed', + source: 'workflow', + description: 'execution_fee', + cost: 0.01, + workflowId: 'wf-1', + workflowName: 'ITSM_Prod_main', + }, + ], + summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, + pagination: { hasMore: false }, + }) + + const response = await GET(createMockRequest('GET')) + const csv = await response.text() + + expect(csv).toContain('Workflow: ITSM_Prod_main') + }) + + it('quotes a Type field that contains a comma', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'fixed', + source: 'workflow', + description: 'execution_fee', + cost: 0.01, + workflowId: 'wf-1', + workflowName: 'Prod, main', + }, + ], + summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, + pagination: { hasMore: false }, + }) + + const response = await GET(createMockRequest('GET')) + const csv = await response.text() + + expect(csv).toContain('"Workflow: Prod, main"') + }) + + it('paginates through getUserUsageLogs until hasMore is false', async () => { + mockGetUserUsageLogs + .mockResolvedValueOnce({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'model', + source: 'copilot', + description: 'claude-opus-4.8', + cost: 0.1, + }, + ], + summary: { totalCost: 0.2, bySource: { copilot: 0.2 } }, + pagination: { hasMore: true, nextCursor: 'log-1' }, + }) + .mockResolvedValueOnce({ + logs: [ + { + id: 'log-2', + createdAt: '2026-06-30T00:00:00.000Z', + category: 'model', + source: 'copilot', + description: 'claude-opus-4.8', + cost: 0.1, + }, + ], + summary: { totalCost: 0.2, bySource: { copilot: 0.2 } }, + pagination: { hasMore: false }, + }) + + const response = await GET(createMockRequest('GET')) + const csv = await response.text() + + expect(mockGetUserUsageLogs).toHaveBeenCalledTimes(2) + expect(mockGetUserUsageLogs).toHaveBeenNthCalledWith( + 2, + 'user-1', + expect.objectContaining({ + cursor: 'log-1', + cursorCreatedAt: new Date('2026-07-01T00:00:00.000Z'), + }) + ) + expect(csv.split('\n')).toHaveLength(3) + }) + + it('apportions credits once over the whole filtered set, not per page', async () => { + mockGetUserUsageLogs + .mockResolvedValueOnce({ + logs: [ + { id: 'log-1', createdAt: '2026-07-01T00:00:00.000Z', source: 'copilot', cost: 0.002 }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-1' }, + }) + .mockResolvedValueOnce({ + logs: [ + { id: 'log-2', createdAt: '2026-06-30T00:00:00.000Z', source: 'copilot', cost: 0.002 }, + ], + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([ + { key: 'log-1', dollars: 0.002 }, + { key: 'log-2', dollars: 0.002 }, + ]) + ) + + await GET(createMockRequest('GET')) + + expect(mockGetUsageCreditsByLogId).toHaveBeenCalledTimes(1) + }) + + it('stops at exactly the safety cap without an extra wasted page fetch', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: Array.from({ length: 50000 }, (_, i) => ({ + id: `log-${i}`, + createdAt: '2026-07-01T00:00:00.000Z', + source: 'copilot', + cost: 0.1, + })), + summary: { totalCost: 0, bySource: {} }, + pagination: { hasMore: true, nextCursor: 'log-49999' }, + }) + + await GET(createMockRequest('GET')) + + expect(mockGetUserUsageLogs).toHaveBeenCalledTimes(1) + }) + + it('rejects "custom" period without a startDate', async () => { + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=custom') + ) + + expect(response.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/users/me/usage-logs/export/route.ts b/apps/sim/app/api/users/me/usage-logs/export/route.ts new file mode 100644 index 00000000000..d32e6d59837 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -0,0 +1,109 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { exportUsageLogsContract } from '@/lib/api/contracts/user' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { + getUsageCreditsByLogId, + getUserUsageLogs, + type UsageLogSource, +} from '@/lib/billing/core/usage-log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { formatCsvValue, toCsvRow } from '@/lib/table/export-format' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' +import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' + +const logger = createLogger('UsageLogsExportAPI') + +/** + * Circuit breaker, not a UX boundary — a personal credit ledger is bounded by + * the user's own usage history and should never realistically approach this. + * Exists only to keep a pathological account (or a bug upstream) from paging + * forever; hitting it is worth alerting on, not a normal truncation case. + */ +const EXPORT_SAFETY_CAP = 50000 +const EXPORT_PAGE_SIZE = 1000 + +const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits']) + +/** + * Downloads every usage log matching the current filter as CSV — unlike the + * paginated list route, this fetches every matching row in one response + * rather than a single page, since a user's own credit ledger is bounded + * (unlike, say, a workspace's full execution history). + */ +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(exportUsageLogsContract, request, {}) + if (!parsed.success) return parsed.response + const { source, workspaceId, period, startDate, endDate } = parsed.data.query + + const dateRange = resolveDateRange(period, startDate, endDate) + const filter = { + source: source as UsageLogSource | undefined, + workspaceId, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + } + + const rows: Awaited>['logs'] = [] + let cursor: string | undefined + let cursorCreatedAt: Date | undefined + let truncated = false + while (rows.length < EXPORT_SAFETY_CAP) { + const page = await getUserUsageLogs(auth.userId, { + ...filter, + limit: Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length), + cursor, + cursorCreatedAt, + includeSummary: false, + }) + rows.push(...page.logs) + if (!page.pagination.hasMore) break + truncated = rows.length >= EXPORT_SAFETY_CAP + cursor = page.pagination.nextCursor + const lastRow = page.logs[page.logs.length - 1] + cursorCreatedAt = lastRow ? new Date(lastRow.createdAt) : undefined + } + + const creditsByLogId = await getUsageCreditsByLogId(auth.userId, filter) + + if (truncated) { + logger.error('Usage log export hit the safety cap — investigate this account', { + userId: auth.userId, + period, + cap: EXPORT_SAFETY_CAP, + }) + } + + const csvLines = rows.map((log) => { + const type = + log.source === 'workflow' && log.workflowName + ? `Workflow: ${log.workflowName}` + : USAGE_LOG_SOURCE_LABELS[log.source] + return toCsvRow([ + formatCsvValue(log.createdAt), + formatCsvValue(type), + formatCsvValue(creditsByLogId[log.id]), + ]) + }) + + const csv = [CSV_HEADER, ...csvLines].join('\n') + const filename = `credit-usage-${period}-${new Date().toISOString().slice(0, 10)}.csv` + + logger.info('Exported usage logs', { userId: auth.userId, period, rowCount: rows.length }) + + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-cache', + 'X-Export-Truncated': truncated ? '1' : '0', + }, + }) +}) 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 index 13c916d4cb7..a45c8a0ff92 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.test.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.test.ts @@ -3,13 +3,16 @@ */ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apportionCredits } from '@/lib/billing/credits/conversion' -const { mockGetUserUsageLogs } = vi.hoisted(() => ({ +const { mockGetUserUsageLogs, mockGetUsageCreditsByLogId } = vi.hoisted(() => ({ mockGetUserUsageLogs: vi.fn(), + mockGetUsageCreditsByLogId: vi.fn(), })) vi.mock('@/lib/billing/core/usage-log', () => ({ getUserUsageLogs: mockGetUserUsageLogs, + getUsageCreditsByLogId: mockGetUsageCreditsByLogId, })) import { GET } from '@/app/api/users/me/usage-logs/route' @@ -32,6 +35,7 @@ describe('GET /api/users/me/usage-logs', () => { summary: { totalCost: 0.5, bySource: { workflow: 0.5 } }, pagination: { hasMore: false }, }) + mockGetUsageCreditsByLogId.mockResolvedValue(apportionCredits([{ key: 'log-1', dollars: 0.5 }])) }) it('returns 401 when unauthenticated', async () => { @@ -51,8 +55,9 @@ describe('GET /api/users/me/usage-logs', () => { id: 'log-1', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', - description: 'gpt-4o', + workflowName: null, creditCost: 100, + dollarCost: 0.5, }, ]) expect(body.summary).toEqual({ @@ -61,6 +66,70 @@ describe('GET /api/users/me/usage-logs', () => { }) }) + it('passes through the workflow name for workflow-sourced rows', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { + id: 'log-1', + createdAt: '2026-07-01T00:00:00.000Z', + category: 'fixed', + source: 'workflow', + description: 'execution_fee', + cost: 0.01, + workflowId: 'wf-1', + workflowName: 'ITSM_Prod_main', + }, + ], + summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([{ key: 'log-1', dollars: 0.01 }]) + ) + + const response = await GET(createMockRequest('GET')) + const body = await response.json() + + expect(body.logs[0].workflowName).toBe('ITSM_Prod_main') + }) + + it('rejects "custom" period without a startDate', async () => { + const response = await GET( + createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=custom') + ) + + expect(response.status).toBe(400) + expect(mockGetUserUsageLogs).not.toHaveBeenCalled() + }) + + it('apportions row credits so they sum exactly to the page total, instead of rounding each row independently', async () => { + mockGetUserUsageLogs.mockResolvedValue({ + logs: [ + { id: 'log-a', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', cost: 0.002 }, + { id: 'log-b', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', cost: 0.002 }, + { id: 'log-c', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', cost: 0.002 }, + ].map((log) => ({ ...log, category: 'model', description: 'gpt-4o' })), + summary: { totalCost: 0.006, bySource: { workflow: 0.006 } }, + pagination: { hasMore: false }, + }) + mockGetUsageCreditsByLogId.mockResolvedValue( + apportionCredits([ + { key: 'log-a', dollars: 0.002 }, + { key: 'log-b', dollars: 0.002 }, + { key: 'log-c', dollars: 0.002 }, + ]) + ) + + const response = await GET(createMockRequest('GET')) + const body = await response.json() + + const rowCreditSum = body.logs.reduce( + (sum: number, log: { creditCost: number }) => sum + log.creditCost, + 0 + ) + expect(rowCreditSum).toBe(body.summary.totalCredits) + }) + it('rejects an invalid period', async () => { const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=1y') @@ -70,6 +139,33 @@ describe('GET /api/users/me/usage-logs', () => { expect(mockGetUserUsageLogs).not.toHaveBeenCalled() }) + it('skips the whole-filter credit apportionment scan when includeCredits=false', async () => { + await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/test?limit=1&includeCredits=false' + ) + ) + + expect(mockGetUsageCreditsByLogId).not.toHaveBeenCalled() + }) + + it('defaults creditCost to 0 (not undefined) when credits were skipped', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/test?limit=1&includeCredits=false' + ) + ) + const body = await response.json() + + expect(body.logs[0].creditCost).toBe(0) + }) + it('resolves the start date from the period filter', async () => { await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=7d')) 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 6502d545bbe..d976d188bc8 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -3,21 +3,17 @@ import { type NextRequest, NextResponse } from 'next/server' 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 { + getUsageCreditsByLogId, + 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' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' 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 -} - /** * Lists the authenticated user's credit-consuming usage events (model, tool, * and fixed charges), converted to credits for display in Billing settings. @@ -30,23 +26,32 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(getUsageLogsContract, request, {}) if (!parsed.success) return parsed.response - const { source, workspaceId, period, limit, cursor } = parsed.data.query + const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } = + parsed.data.query + + const dateRange = resolveDateRange(period, startDate, endDate) - const result = await getUserUsageLogs(auth.userId, { + const filter = { source: source as UsageLogSource | undefined, workspaceId, - startDate: resolveStartDate(period), - endDate: new Date(), - limit, - cursor, - }) + startDate: dateRange.startDate, + endDate: dateRange.endDate, + } + + const [result, creditsByLogId] = await Promise.all([ + getUserUsageLogs(auth.userId, { ...filter, limit, cursor }), + includeCredits + ? getUsageCreditsByLogId(auth.userId, filter) + : Promise.resolve>({}), + ]) const logs = result.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, source: log.source, - description: log.description, - creditCost: dollarsToCredits(log.cost), + workflowName: log.workflowName ?? null, + creditCost: creditsByLogId[log.id] ?? 0, + dollarCost: log.cost, })) const bySourceCredits = Object.fromEntries( diff --git a/apps/sim/app/api/users/me/usage-logs/shared.test.ts b/apps/sim/app/api/users/me/usage-logs/shared.test.ts new file mode 100644 index 00000000000..2f623fa1279 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/shared.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' + +describe('resolveDateRange', () => { + it('throws when period is "custom" without a startDate', () => { + expect(() => resolveDateRange('custom', undefined, undefined)).toThrow( + 'startDate is required when period is "custom"' + ) + }) + + it('defaults endDate to now when omitted for a custom period', () => { + const range = resolveDateRange('custom', '2026-01-01T00:00', undefined) + + expect(range.startDate).toEqual(new Date('2026-01-01T00:00')) + expect(range.endDate.getTime()).toBeCloseTo(Date.now(), -3) + }) + + it('uses both bounds when provided for a custom period', () => { + const range = resolveDateRange('custom', '2026-01-01T00:00', '2026-01-31T00:00') + + expect(range.startDate).toEqual(new Date('2026-01-01T00:00')) + expect(range.endDate).toEqual(new Date('2026-01-31T00:00')) + }) + + it('omits startDate for the "all" period', () => { + const range = resolveDateRange('all', undefined, undefined) + + expect(range.startDate).toBeUndefined() + }) + + it('resolves a startDate N days back for a preset period', () => { + const range = resolveDateRange('7d', undefined, undefined) + + const expected = new Date() + expected.setDate(expected.getDate() - 7) + expect(range.startDate?.toDateString()).toBe(expected.toDateString()) + }) +}) diff --git a/apps/sim/app/api/users/me/usage-logs/shared.ts b/apps/sim/app/api/users/me/usage-logs/shared.ts new file mode 100644 index 00000000000..1c143248edc --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -0,0 +1,28 @@ +import type { UsageLogPeriod } from '@/lib/api/contracts/user' + +const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 } + +interface ResolvedDateRange { + startDate: Date | undefined + endDate: Date +} + +/** Shared by the list and export routes so their date-filtering can never drift. */ +export function resolveDateRange( + period: UsageLogPeriod, + customStartDate: string | undefined, + customEndDate: string | undefined +): ResolvedDateRange { + if (period === 'custom') { + if (!customStartDate) throw new Error('startDate is required when period is "custom"') + return { + startDate: new Date(customStartDate), + endDate: customEndDate ? new Date(customEndDate) : new Date(), + } + } + if (period === 'all') return { startDate: undefined, endDate: new Date() } + + const startDate = new Date() + startDate.setDate(startDate.getDate() - PERIOD_TO_DAYS[period]) + return { startDate, endDate: new Date() } +} diff --git a/apps/sim/app/api/users/me/usage-logs/source-labels.ts b/apps/sim/app/api/users/me/usage-logs/source-labels.ts new file mode 100644 index 00000000000..da3dc138706 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/source-labels.ts @@ -0,0 +1,20 @@ +import type { UsageLogSource } from '@/lib/api/contracts/user' + +/** + * Humanized labels for `usage_log.source`, shared by the Credit usage page's + * row rendering and the CSV export so both read identically. Avoids the + * internal "copilot" / "mothership" naming — the agent is always "Sim", the + * surface is "Chat". Pure data, no server-only imports, so it's safe from + * both the client page and the export route. + */ +export const USAGE_LOG_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', +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx new file mode 100644 index 00000000000..5e29dfc68f1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -0,0 +1,247 @@ +'use client' + +import { useState } from 'react' +import { + Calendar, + Chip, + ChipCombobox, + ChipLink, + type ComboboxOption, + chipVariants, + cn, + Popover, + PopoverAnchor, + PopoverContent, + toast, +} from '@sim/emcn' +import { ArrowLeft, Download } from '@sim/emcn/icons' +import { formatDateTime } from '@sim/utils/formatting' +import { useQueryStates } from 'nuqs' +import type { UsageLogEntry, UsageLogPeriod } from '@/lib/api/contracts/user' +import { formatApportionedCreditCost, formatCreditsLabel } from '@/lib/billing/credits/conversion' +import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-labels' +import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils' +import { + creditUsageParsers, + creditUsageUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { useUsageLogs } from '@/hooks/queries/usage-logs' + +const PERIOD_OPTIONS: ComboboxOption[] = [ + { value: '1d', label: 'Today' }, + { value: '7d', label: 'Last 7 days' }, + { value: '30d', label: 'Last 30 days' }, + { value: 'all', label: 'All time' }, + { value: 'custom', label: 'Custom range' }, +] + +/** Workflow-sourced rows name the specific workflow; everything else uses the plain source label. */ +function rowLabel(log: UsageLogEntry): string { + if (log.source === 'workflow' && log.workflowName) return `Workflow: ${log.workflowName}` + return USAGE_LOG_SOURCE_LABELS[log.source] +} + +interface UsageLogRowProps { + log: UsageLogEntry +} + +function UsageLogRow({ log }: UsageLogRowProps) { + return ( +
+ + {formatDateTime(new Date(log.createdAt))} + + + {rowLabel(log)} + + + {formatApportionedCreditCost(log.creditCost, log.dollarCost)} + +
+ ) +} + +interface CreditUsageViewProps { + workspaceId: string +} + +export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { + const billingHref = `/workspace/${workspaceId}/settings/billing` + + const [{ period, startDate, endDate }, setFilters] = useQueryStates( + creditUsageParsers, + creditUsageUrlKeys + ) + const [datePickerOpen, setDatePickerOpen] = useState(false) + + const handlePeriodChange = (value: string) => { + if (value === 'custom') { + setDatePickerOpen(true) + return + } + setFilters({ period: value as UsageLogPeriod, startDate: null, endDate: null }) + } + + const handleDateRangeApply = (nextStart: string, nextEnd: string) => { + setFilters({ period: 'custom', startDate: nextStart, endDate: nextEnd }) + setDatePickerOpen(false) + } + + const handleDatePickerCancel = () => { + setDatePickerOpen(false) + } + + /** + * Downloads a CSV of every log matching the current filter. Fetches rather + * than navigating a plain anchor to the export URL so the client can read + * the `X-Export-Truncated` response header and surface it — an anchor + * navigation has no way to inspect the response before the browser commits + * to the download. + */ + const handleExport = async () => { + const params = new URLSearchParams({ period }) + if (period === 'custom') { + if (startDate) params.set('startDate', startDate) + if (endDate) params.set('endDate', endDate) + } + + // boundary-raw-fetch: downloads a CSV blob and reads a response header before saving — a plain anchor navigation can't do either + const response = await fetch(`/api/users/me/usage-logs/export?${params.toString()}`) + if (!response.ok) { + toast.error('Failed to export usage logs') + return + } + if (response.headers.get('X-Export-Truncated') === '1') { + toast.info('Export truncated — narrow the date range to see everything') + } + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `credit-usage-${period}-${new Date().toISOString().slice(0, 10)}.csv` + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + } + + const periodDisplayLabel = + period === 'custom' && startDate && endDate + ? `${formatDateShort(startDate)} - ${formatDateShort(endDate)}` + : (PERIOD_OPTIONS.find((option) => option.value === period)?.label ?? 'Last 30 days') + + const { + data, + isLoading, + isError, + isPlaceholderData, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useUsageLogs({ + period, + startDate: period === 'custom' ? startDate || undefined : undefined, + endDate: period === 'custom' ? endDate || undefined : undefined, + }) + + const logs = data?.pages.flatMap((page) => page.logs) ?? [] + const totalCredits = data?.pages[0]?.summary.totalCredits ?? 0 + + return ( + + Billing + + } + actions={ + void handleExport()} + disabled={logs.length === 0 || isPlaceholderData} + > + Export + + } + > +
+

Credit usage

+

+ Every credit-consuming event behind your usage. +

+
+ +
+ + Total: {formatCreditsLabel(totalCredits)} + +
+ {periodDisplayLabel} + } + /> + { + if (!isOpen) handleDatePickerCancel() + }} + > + + + + + +
+
+ +
+ {isLoading ? ( + Loading usage… + ) : isError ? ( + Couldn't load credit usage. + ) : logs.length === 0 ? ( + No credit usage in this period. + ) : ( + <> + {logs.map((log) => ( + + ))} + {hasNextPage && ( + + )} + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx new file mode 100644 index 00000000000..6f8fe66f7b6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Chip } from '@sim/emcn' +import { ArrowLeft } from '@sim/emcn/icons' +import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail' + +/** + * Route-level loading fallback (Next.js convention) and the `Suspense` + * fallback in `page.tsx` — `CreditUsageView` reads `useSearchParams` via + * nuqs, so it must suspend behind a boundary. Rendering the real chrome + * here means a suspend never flashes a blank frame. + */ +export default function CreditUsageLoading() { + return ( + + Billing + + } + > +
+

Credit usage

+

+ Every credit-consuming event behind your usage. +

+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx new file mode 100644 index 00000000000..367f1cdbb7e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx @@ -0,0 +1,46 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' +import { isEnterprise } from '@/lib/billing/plan-helpers' +import { CreditUsageView } from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view' +import CreditUsageLoading from '@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading' + +export const metadata: Metadata = { + title: 'Credit usage', +} + +/** + * `CreditUsageView` reads URL query params via nuqs (which uses + * `useSearchParams` internally), so it must sit under a Suspense boundary. + * The fallback renders the real chrome so a suspend never shows a blank + * frame — `loading.tsx` covers the route-navigation transition the same way. + * + * Enterprise accounts manage billing out-of-band and never see this page — + * Billing settings already hides the link to it, but hiding a link doesn't + * stop direct navigation, so this redirects server-side before anything + * renders (matching `getHighestPrioritySubscription`'s use elsewhere for + * server-side plan checks). + */ +export default async function CreditUsagePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + + const session = await getSession() + if (session?.user?.id) { + const subscription = await getHighestPrioritySubscription(session.user.id) + if (isEnterprise(subscription?.plan)) { + redirect(`/workspace/${workspaceId}/settings/billing`) + } + } + + return ( + }> + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params.ts new file mode 100644 index 00000000000..9c16d786bed --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params.ts @@ -0,0 +1,25 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { usageLogPeriodSchema } from '@/lib/api/contracts/user' + +/** + * Co-located, typed URL query-param definitions for the Credit usage page. + * + * - `period` shares its literal values with {@link usageLogPeriodSchema} so + * the URL parser can never drift from the API contract it filters. + * - `startDate`/`endDate` are the applied custom range bounds, only + * meaningful when `period` is `'custom'`. No static default makes sense for + * them, so they're left nullable rather than defaulted to `''` — matching + * the equivalent fields in the main Logs page's search-params.ts. + */ +export const creditUsageParsers = { + period: parseAsStringLiteral(usageLogPeriodSchema.options).withDefault('30d'), + startDate: parseAsString, + endDate: parseAsString, +} as const + +/** Filter view-state: clean URLs, no back-stack churn. */ +export const creditUsageUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const 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 466ea81e4f2..dc59e5b5f39 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -642,7 +642,7 @@ export function Billing() { )} - {!subscription.isEnterprise && } + {!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 index 68aff42692b..2c8f4a993af 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,125 +1,37 @@ '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 { ChipLink } from '@sim/emcn' 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' +import { useUsageSummary } 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' }, -] +/** Period the compact Billing glance summarizes; the full page offers finer control. */ +const SUMMARY_PERIOD = '30d' -/** - * 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)} - -
- ) +interface CreditUsageSectionProps { + workspaceId: string } /** - * 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. + * Compact "how much have I used" glance in Billing settings — a single total + * plus a link to the full, filterable Credit usage page. Shown to every plan + * except Enterprise, which manages billing out-of-band. */ -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 +export function CreditUsageSection({ workspaceId }: CreditUsageSectionProps) { + const { data: totalCredits, isPending, isError } = useUsageSummary(SUMMARY_PERIOD) 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 && ( - - )} - - )} + +
+
+ + {isPending || isError ? '—' : formatCreditsLabel(totalCredits ?? 0)} + + Last 30 days +
+ + View usage logs +
) 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 deleted file mode 100644 index 54e2eb2ce04..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/search-params.ts +++ /dev/null @@ -1,21 +0,0 @@ -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/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx index 349eb829328..0819fa25782 100644 --- a/apps/sim/ee/audit-logs/components/audit-logs.tsx +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -5,15 +5,18 @@ import { Badge, Button, Calendar, + ChipCombobox, ChipInput, ChipSelect, type ComboboxOption, cn, + Download, Popover, PopoverAnchor, PopoverContent, RefreshCw, Search, + toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { formatDateTime } from '@sim/utils/formatting' @@ -32,16 +35,14 @@ const logger = createLogger('AuditLogs') const REFRESH_SPINNER_DURATION_MS = 1000 +/** Trimmed to the most commonly used granularities so the menu fits without scrolling. */ const TIME_RANGE_OPTIONS: ComboboxOption[] = [ { value: 'All time', label: 'All time' }, - { value: 'Past 30 minutes', label: 'Past 30 minutes' }, { value: 'Past hour', label: 'Past hour' }, { value: 'Past 6 hours', label: 'Past 6 hours' }, - { value: 'Past 12 hours', label: 'Past 12 hours' }, { value: 'Past 24 hours', label: 'Past 24 hours' }, { value: 'Past 3 days', label: 'Past 3 days' }, { value: 'Past 7 days', label: 'Past 7 days' }, - { value: 'Past 14 days', label: 'Past 14 days' }, { value: 'Past 30 days', label: 'Past 30 days' }, { value: 'Custom range', label: 'Custom range' }, ] @@ -267,6 +268,7 @@ export function AuditLogs() { const debounceRef = useRef | null>(null) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const refreshTimersRef = useRef(new Set()) + const [isExporting, setIsExporting] = useState(false) useEffect(() => { const trimmed = searchTerm.trim() @@ -295,8 +297,15 @@ export function AuditLogs() { } }, [debouncedSearch, selectedTypes, timeRange, customStartDate, customEndDate]) - const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch } = - useAuditLogs(filters) + const { + data, + isLoading, + isPlaceholderData, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch, + } = useAuditLogs(filters) const allEntries = useMemo(() => { if (!data?.pages) return [] @@ -361,8 +370,50 @@ export function AuditLogs() { } }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + const handleExportCsv = async () => { + setIsExporting(true) + try { + const params = new URLSearchParams() + if (filters.search) params.set('search', filters.search) + if (filters.resourceType) params.set('resourceType', filters.resourceType) + if (filters.startDate) params.set('startDate', filters.startDate) + if (filters.endDate) params.set('endDate', filters.endDate) + + // boundary-raw-fetch: downloads a CSV blob and reads a response header before saving — a plain anchor navigation can't do either + const response = await fetch(`/api/audit-logs/export?${params.toString()}`) + if (!response.ok) { + toast.error('Failed to export audit logs') + return + } + if (response.headers.get('X-Export-Truncated') === '1') { + toast.info('Export truncated — narrow the date range to see everything') + } + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv` + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) + } finally { + setIsExporting(false) + } + } + return ( - + void handleExportCsv(), + disabled: allEntries.length === 0 || isExporting || isPlaceholderData, + }, + ]} + >
- {timeDisplayLabel} + } maxHeight={320} align='start' /> diff --git a/apps/sim/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts index aabd57c6cdb..f67bb058f51 100644 --- a/apps/sim/hooks/queries/usage-logs.ts +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -1,6 +1,6 @@ 'use client' -import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' +import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { getUsageLogsContract, @@ -11,32 +11,42 @@ import { usageLogKeys } from '@/hooks/queries/utils/usage-log-keys' const PAGE_SIZE = 25 +interface UsagePeriodFilter { + period: UsageLogPeriod + /** Required when `period` is `'custom'`. */ + startDate?: string + /** Required when `period` is `'custom'`. */ + endDate?: string +} + async function fetchUsageLogs( - period: UsageLogPeriod, + filter: UsagePeriodFilter, + limit: number, cursor: string | undefined, - signal?: AbortSignal + signal?: AbortSignal, + includeCredits = true ): Promise { return requestJson(getUsageLogsContract, { - query: { period, limit: PAGE_SIZE, cursor }, + query: { ...filter, limit, cursor, includeCredits }, signal, }) } -interface UseUsageLogsOptions { - period: UsageLogPeriod +interface UseUsageLogsOptions extends UsagePeriodFilter { 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. + * the Credit usage page, keyset-paginated by the backend's opaque + * `nextCursor`. Keeps the prior filter's rows on screen while a newly + * selected period/range loads, since the filter is a variable key. */ -export function useUsageLogs({ period, enabled = true }: UseUsageLogsOptions) { +export function useUsageLogs({ period, startDate, endDate, enabled = true }: UseUsageLogsOptions) { return useInfiniteQuery({ - queryKey: usageLogKeys.list(period), - queryFn: ({ pageParam, signal }) => fetchUsageLogs(period, pageParam, signal), + queryKey: usageLogKeys.list(period, undefined, { startDate, endDate }), + queryFn: ({ pageParam, signal }) => + fetchUsageLogs({ period, startDate, endDate }, PAGE_SIZE, pageParam, signal), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.pagination.hasMore ? lastPage.pagination.nextCursor : undefined, @@ -45,3 +55,17 @@ export function useUsageLogs({ period, enabled = true }: UseUsageLogsOptions) { placeholderData: keepPreviousData, }) } + +/** + * Fetches just the total-credits summary for a fixed period — the compact + * Billing settings glance doesn't need the paginated row list, so this skips + * the infinite-query machinery and asks the backend for a single minimal page. + */ +export function useUsageSummary(period: Exclude) { + return useQuery({ + queryKey: usageLogKeys.summary(period), + queryFn: ({ signal }) => fetchUsageLogs({ period }, 1, undefined, signal, false), + staleTime: 30 * 1000, + select: (data) => data.summary.totalCredits, + }) +} diff --git a/apps/sim/hooks/queries/utils/usage-log-keys.ts b/apps/sim/hooks/queries/utils/usage-log-keys.ts index 2911f3ca69d..1e147e441a8 100644 --- a/apps/sim/hooks/queries/utils/usage-log-keys.ts +++ b/apps/sim/hooks/queries/utils/usage-log-keys.ts @@ -10,9 +10,22 @@ import type { UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user' +export interface UsageLogDateRange { + startDate?: string + endDate?: string +} + 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, + list: (period: UsageLogPeriod, source?: UsageLogSource, dateRange?: UsageLogDateRange) => + [ + ...usageLogKeys.lists(), + period, + source ?? '', + dateRange?.startDate ?? '', + dateRange?.endDate ?? '', + ] as const, + summaries: () => [...usageLogKeys.all, 'summary'] as const, + summary: (period: UsageLogPeriod) => [...usageLogKeys.summaries(), period] as const, } diff --git a/apps/sim/lib/api/contracts/audit-logs.ts b/apps/sim/lib/api/contracts/audit-logs.ts index c561d23bb02..39a7c777123 100644 --- a/apps/sim/lib/api/contracts/audit-logs.ts +++ b/apps/sim/lib/api/contracts/audit-logs.ts @@ -67,3 +67,21 @@ export const listAuditLogsContract = defineRouteContract({ schema: listAuditLogsResponseSchema, }, }) + +export const exportAuditLogsQuerySchema = auditLogsQuerySchema.omit({ limit: true, cursor: true }) +export type ExportAuditLogsQuery = z.output + +/** + * CSV download of every audit log matching the filter (no pagination). `mode: + * 'text'` because a CSV response has no JSON schema to validate; the client + * triggers this via `fetch` + blob (not `requestJson`), so there's no + * response shape for a consumer to type. + */ +export const exportAuditLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/audit-logs/export', + query: exportAuditLogsQuerySchema, + response: { + mode: 'text', + }, +}) diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 3d4c0d79532..7da4c3597d0 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' import { isSameOrigin } from '@/lib/core/utils/validation' @@ -276,23 +277,73 @@ export const usageLogSourceSchema = z.enum([ 'enrichment', ]) -export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all']) +export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all', 'custom']) -export const usageLogsQuerySchema = z.object({ +/** + * `Date`-constructor-parseable string — the {@link Calendar} range picker + * emits local `YYYY-MM-DDTHH:mm`, not strict ISO 8601, so this validates + * parseability rather than a specific wire format. + */ +const parseableDateSchema = z + .string() + .min(1) + .refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' }) + +/** Shared by the paginated list query and the export query — filters only, no pagination. */ +const usageLogsFilterSchema = z.object({ source: usageLogSourceSchema.optional(), workspaceId: z.string().optional(), period: usageLogPeriodSchema.optional().default('30d'), - limit: z.coerce.number().min(1).max(100).optional().default(50), - cursor: z.string().optional(), + /** Required when `period` is `'custom'`. */ + startDate: parseableDateSchema.optional(), + /** Defaults to now when omitted for `'custom'`. */ + endDate: parseableDateSchema.optional(), }) +/** Both the list and export query schemas require startDate whenever period is 'custom'. */ +const startDateRequiredForCustomPeriod = { + error: 'startDate is required when period is "custom"', + path: ['startDate'], +} + +export const usageLogsQuerySchema = usageLogsFilterSchema + .extend({ + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), + /** + * The compact Billing summary glance (`limit: 1`) only reads + * `summary.totalCredits`, never a row's `creditCost` — set `false` to + * skip the whole-filter apportionment scan for that caller. + */ + includeCredits: booleanQueryFlagSchema.optional().default(true), + }) + .refine( + (query) => query.period !== 'custom' || query.startDate !== undefined, + startDateRequiredForCustomPeriod + ) + +/** Same filters as the list query, without pagination — the export route returns every match. */ +export const exportUsageLogsQuerySchema = usageLogsFilterSchema.refine( + (query) => query.period !== 'custom' || query.startDate !== undefined, + startDateRequiredForCustomPeriod +) + 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). */ + /** Specific workflow name, populated only when `source` is `'workflow'`. */ + workflowName: z.string().nullable(), + /** + * Credit-denominated cost of this event (Sim's usage unit; 1,000 credits = + * $5), apportioned across the page so row credits always sum exactly to + * the page's rounded total — this can legitimately be 0 for a row with a + * real but sub-credit `dollarCost` once a sibling row absorbs the shared + * rounding remainder. + */ creditCost: z.number(), + /** Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event. */ + dollarCost: z.number(), }) export const usageLogsApiResponseSchema = z.object({ @@ -318,10 +369,26 @@ export const getUsageLogsContract = defineRouteContract({ }, }) +/** + * CSV download of every usage log matching the filter (no pagination). `mode: + * 'text'` because a CSV response has no JSON schema to validate; the client + * triggers this as a plain browser download (an anchor navigation), never + * through `requestJson`, so there's no response shape for a consumer to type. + */ +export const exportUsageLogsContract = defineRouteContract({ + method: 'GET', + path: '/api/users/me/usage-logs/export', + query: exportUsageLogsQuerySchema, + response: { + mode: 'text', + }, +}) + export type UsageLogSource = z.output export type UsageLogPeriod = z.output export type UsageLogEntry = z.output export type UsageLogsApiResponse = z.output +export type ExportUsageLogsQuery = 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/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 034609a65f3..0638d31472d 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -1,12 +1,13 @@ import { createHash } from 'node:crypto' import { db, dbReplica } from '@sim/db' -import { usageLog, workspace } from '@sim/db/schema' +import { usageLog, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, desc, eq, gte, inArray, lt, lte, sql } from 'drizzle-orm' +import { and, desc, eq, gte, inArray, lt, lte, or, sql } from 'drizzle-orm' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' +import { apportionCredits } from '@/lib/billing/credits/conversion' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import type { DbClient, DbOrTx } from '@/lib/db/types' @@ -517,6 +518,45 @@ export async function recordCumulativeUsage( }) } +interface UsageLogFilter { + source?: UsageLogSource + workspaceId?: string + startDate?: Date + endDate?: Date +} + +function buildUsageLogConditions(userId: string, filter: UsageLogFilter) { + const conditions = [eq(usageLog.userId, userId)] + if (filter.source) conditions.push(eq(usageLog.source, filter.source)) + if (filter.workspaceId) conditions.push(eq(usageLog.workspaceId, filter.workspaceId)) + if (filter.startDate) conditions.push(gte(usageLog.createdAt, filter.startDate)) + if (filter.endDate) conditions.push(lte(usageLog.createdAt, filter.endDate)) + return conditions +} + +/** + * Apportions credits across every log matching the filter (not just one + * page), so a row's `creditCost` is identical everywhere it's shown — the + * paginated list and the CSV export both call this rather than each + * apportioning their own subset, which would let the same row disagree + * between the two (or between pages of the same list) since apportionment + * depends on the complete set's total. + */ +export async function getUsageCreditsByLogId( + userId: string, + filter: UsageLogFilter +): Promise> { + const rows = await dbReplica + .select({ id: usageLog.id, cost: usageLog.cost }) + .from(usageLog) + .where(and(...buildUsageLogConditions(userId, filter))) + .orderBy(desc(usageLog.createdAt), desc(usageLog.id)) + + return apportionCredits( + rows.map((row) => ({ key: row.id, dollars: Number.parseFloat(row.cost) })) + ) +} + /** * Options for querying usage logs */ @@ -533,6 +573,20 @@ export interface GetUsageLogsOptions { limit?: number /** Cursor for pagination (log ID) */ cursor?: string + /** + * The cursor row's `createdAt`, when the caller already has it (e.g. a + * multi-page export loop holding the previous page's rows in memory). + * Skips the row lookup that would otherwise resolve it from `cursor`. + */ + cursorCreatedAt?: Date + /** + * Whether to compute the full-filter `summary` aggregate (default `true`). + * A cursor-paginated caller collecting every page (e.g. a CSV export) only + * needs `logs` from each page and would otherwise pay for the same + * cursor-independent `SUM`/`GROUP BY` scan once per page for a result it + * never reads — set `false` to skip it. + */ + includeSummary?: boolean } /** @@ -548,6 +602,8 @@ interface UsageLogEntry { cost: number workspaceId?: string workflowId?: string + /** Name of the referenced workflow, when `workflowId` resolves to one. */ + workflowName?: string executionId?: string } @@ -556,6 +612,7 @@ interface UsageLogEntry { */ export interface UsageLogsResult { logs: UsageLogEntry[] + /** `{ totalCost: 0, bySource: {} }` when `includeSummary` is `false`. */ summary: { totalCost: number bySource: Record @@ -573,47 +630,60 @@ export async function getUserUsageLogs( userId: string, options: GetUsageLogsOptions = {} ): Promise { - const { source, workspaceId, startDate, endDate, limit = 50, cursor } = options + const { + source, + workspaceId, + startDate, + endDate, + limit = 50, + cursor, + cursorCreatedAt, + includeSummary = true, + } = options try { - const conditions = [eq(usageLog.userId, userId)] - - if (source) { - conditions.push(eq(usageLog.source, source)) - } - - if (workspaceId) { - conditions.push(eq(usageLog.workspaceId, workspaceId)) - } - - if (startDate) { - conditions.push(gte(usageLog.createdAt, startDate)) - } - - if (endDate) { - conditions.push(lte(usageLog.createdAt, endDate)) - } + const conditions = buildUsageLogConditions(userId, { source, workspaceId, startDate, endDate }) if (cursor) { - // Cursor resolution stays on the primary: the page itself reads a - // load-balanced replica, and a laggier sibling replica missing the cursor - // row would silently restart pagination from page 1. - const cursorLog = await db - .select({ createdAt: usageLog.createdAt }) - .from(usageLog) - .where(eq(usageLog.id, cursor)) - .limit(1) + let resolvedCursorCreatedAt = cursorCreatedAt + + if (!resolvedCursorCreatedAt) { + // Cursor resolution stays on the primary: the page itself reads a + // load-balanced replica, and a laggier sibling replica missing the + // cursor row would silently restart pagination from page 1. + const cursorLog = await db + .select({ createdAt: usageLog.createdAt }) + .from(usageLog) + .where(eq(usageLog.id, cursor)) + .limit(1) + resolvedCursorCreatedAt = cursorLog[0]?.createdAt + } - if (cursorLog.length > 0) { - conditions.push( - sql`(${usageLog.createdAt} < ${cursorLog[0].createdAt} OR (${usageLog.createdAt} = ${cursorLog[0].createdAt} AND ${usageLog.id} < ${cursor}))` + if (resolvedCursorCreatedAt) { + const cursorCondition = or( + lt(usageLog.createdAt, resolvedCursorCreatedAt), + and(eq(usageLog.createdAt, resolvedCursorCreatedAt), lt(usageLog.id, cursor)) ) + if (cursorCondition) conditions.push(cursorCondition) } } const logs = await dbReplica - .select() + .select({ + id: usageLog.id, + createdAt: usageLog.createdAt, + category: usageLog.category, + source: usageLog.source, + description: usageLog.description, + metadata: usageLog.metadata, + cost: usageLog.cost, + workspaceId: usageLog.workspaceId, + workflowId: usageLog.workflowId, + workflowName: workflow.name, + executionId: usageLog.executionId, + }) .from(usageLog) + .leftJoin(workflow, eq(usageLog.workflowId, workflow.id)) .where(and(...conditions)) .orderBy(desc(usageLog.createdAt), desc(usageLog.id)) .limit(limit + 1) @@ -631,31 +701,35 @@ export async function getUserUsageLogs( cost: Number.parseFloat(log.cost), ...(log.workspaceId ? { workspaceId: log.workspaceId } : {}), ...(log.workflowId ? { workflowId: log.workflowId } : {}), + ...(log.workflowName ? { workflowName: log.workflowName } : {}), ...(log.executionId ? { executionId: log.executionId } : {}), })) - const summaryConditions = [eq(usageLog.userId, userId)] - if (source) summaryConditions.push(eq(usageLog.source, source)) - if (workspaceId) summaryConditions.push(eq(usageLog.workspaceId, workspaceId)) - if (startDate) summaryConditions.push(gte(usageLog.createdAt, startDate)) - if (endDate) summaryConditions.push(lte(usageLog.createdAt, endDate)) + const bySource: Record = {} + let totalCost = 0 - const summaryResult = await dbReplica - .select({ - source: usageLog.source, - totalCost: sql`SUM(${usageLog.cost})`, + if (includeSummary) { + const summaryConditions = buildUsageLogConditions(userId, { + source, + workspaceId, + startDate, + endDate, }) - .from(usageLog) - .where(and(...summaryConditions)) - .groupBy(usageLog.source) - const bySource: Record = {} - let totalCost = 0 + const summaryResult = await dbReplica + .select({ + source: usageLog.source, + totalCost: sql`SUM(${usageLog.cost})`, + }) + .from(usageLog) + .where(and(...summaryConditions)) + .groupBy(usageLog.source) - for (const row of summaryResult) { - const sourceCost = Number.parseFloat(row.totalCost || '0') - bySource[row.source] = sourceCost - totalCost += sourceCost + for (const row of summaryResult) { + const sourceCost = Number.parseFloat(row.totalCost || '0') + bySource[row.source] = sourceCost + totalCost += sourceCost + } } return { diff --git a/apps/sim/lib/billing/credits/conversion.ts b/apps/sim/lib/billing/credits/conversion.ts index 1a24d40ebd4..59a11264c85 100644 --- a/apps/sim/lib/billing/credits/conversion.ts +++ b/apps/sim/lib/billing/credits/conversion.ts @@ -65,6 +65,20 @@ export function formatCreditCost( return formatCreditsLabel(credits) } +/** + * Renders an already-apportioned integer `creditCost` (see {@link apportionCredits}) + * alongside its raw `dollarCost`, so a row can legitimately apportion to 0 + * credits — once a sibling absorbs the shared rounding remainder — without + * reading as a flat, misleading "0 credits" for an event that had a real, + * positive charge. Mirrors {@link formatCreditCost}'s zero/sub-credit + * wording, but never recomputes credits from `dollarCost` (that would + * double-convert a value the caller already apportioned). + */ +export function formatApportionedCreditCost(creditCost: number, dollarCost: number): string { + if (creditCost > 0) return formatCreditsLabel(creditCost) + return dollarCost > 0 ? '<1 credit' : '0 credits' +} + /** * Splits a set of cost components into integer credits that sum *exactly* to * the credits of their combined total. diff --git a/packages/emcn/src/components/chip-select/chip-select.tsx b/packages/emcn/src/components/chip-select/chip-select.tsx index c4fb30b975e..05565b47034 100644 --- a/packages/emcn/src/components/chip-select/chip-select.tsx +++ b/packages/emcn/src/components/chip-select/chip-select.tsx @@ -77,6 +77,14 @@ export interface ChipSelectProps { contentClassName?: string /** Accessible label for the trigger. */ 'aria-label'?: string + /** + * Forwarded to the underlying `DropdownMenu`'s Radix `modal` prop + * (default `true`, matching Radix). Set `false` when an `onChange` handler + * opens a second overlay (e.g. a `Popover`) in the same tick a selection is + * made — a modal menu's focus-lock teardown can trap that overlay + * non-interactive. + */ + modal?: boolean } /** @@ -121,6 +129,7 @@ export function ChipSelect({ className, contentClassName, 'aria-label': ariaLabel, + modal, }: ChipSelectProps) { const [query, setQuery] = React.useState('') @@ -207,6 +216,7 @@ export function ChipSelect({ return ( { if (!open) setQuery('') }} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 4858463d4a5..d9037f34494 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 884, - zodRoutes: 884, + totalRoutes: 885, + zodRoutes: 885, nonZodRoutes: 0, } as const