From fa8abc0a4c2c0ee0eb348d88540941ba69340bd0 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 13:29:13 -0700 Subject: [PATCH 01/17] fix(billing): apportion per-row credit costs so they sum to the page total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot (medium): each row rounded its own dollar cost to credits independently while the header total rounded the summed dollars once — over enough rows those two roundings can visibly disagree, the exact "line items don't add up to the total" class of bug apportionCredits was already built to prevent (used by the trace view / cost breakdown). Route now apportions each page's row credits against that page's dollar sum instead of rounding rows independently. Added a test with three sub-cent rows that would each independently round to 0 credits (but sum to 1) to prove the reconciliation holds. --- .../app/api/users/me/usage-logs/route.test.ts | 25 +++++++++++++++++++ apps/sim/app/api/users/me/usage-logs/route.ts | 13 ++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) 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..e8fc6c10c28 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 @@ -61,6 +61,31 @@ describe('GET /api/users/me/usage-logs', () => { }) }) + it('apportions row credits so they sum exactly to the page total, instead of rounding each row independently', async () => { + // Each row costs $0.002 (0.4 credits) — independent per-row rounding + // would floor every row to 0 credits while the combined $0.006 rounds to + // 1, visibly failing to add up. Apportioning must place that 1 credit on + // exactly one row so the displayed rows sum to the displayed total. + 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 }, + }) + + 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') 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..7fa94c6bf5d 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -4,7 +4,7 @@ import { getUsageLogsContract } from '@/lib/api/contracts/user' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' -import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UsageLogsAPI') @@ -41,12 +41,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => { cursor, }) + // Apportioned (not independently rounded per row) so this page's visible + // credit costs always sum to exactly `dollarsToCredits(sum of this page's + // dollars)` — rounding each row on its own can drift from that sum by + // several credits over enough rows, which reads as "the numbers don't add + // up" next to the period total. + const creditsByLogId = apportionCredits( + result.logs.map((log) => ({ key: log.id, dollars: log.cost })) + ) + const logs = result.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, source: log.source, description: log.description, - creditCost: dollarsToCredits(log.cost), + creditCost: creditsByLogId[log.id], })) const bySourceCredits = Object.fromEntries( From 06a4c1420e416ee0a24101efb9c21f707139065b Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 13:41:03 -0700 Subject: [PATCH 02/17] fix(billing): dim stale credit usage rows while a new period loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot (medium): keepPreviousData kept the prior period's rows and total on screen while a newly selected period fetched, but the dropdown label updated immediately — so during the transition the displayed numbers were labeled under a period they didn't belong to. Now reads isPlaceholderData (the standard TanStack Query signal for "this data is a stale placeholder, not a fresh fetch for the current key") and dims the list while it's true, matching the same flag already used for this exact purpose in integration-skills-section.tsx. --- .../credit-usage-section.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 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 68aff42692b..6b1b2024152 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 @@ -68,9 +68,15 @@ function UsageLogRow({ log }: UsageLogRowProps) { export function CreditUsageSection() { const [{ period }, setFilters] = useQueryStates(billingParsers, billingUrlKeys) - const { data, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } = useUsageLogs( - { period } - ) + const { + data, + isLoading, + isError, + isPlaceholderData, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useUsageLogs({ period }) const logs = data?.pages.flatMap((page) => page.logs) ?? [] const totalCredits = data?.pages[0]?.summary.totalCredits ?? 0 @@ -86,7 +92,16 @@ export function CreditUsageSection() { /> } > -
+ {/* isPlaceholderData means these rows/total are the still-displayed prior + period's data while the newly selected period is in flight — dim them + so they don't read as settled results for the period now shown in the + dropdown. */} +
{isLoading ? ( Loading usage… ) : isError ? ( From eb26a010e05c9da0c3a27fa93abbc2fff384ecf4 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 13:51:26 -0700 Subject: [PATCH 03/17] fix(billing): show "<1 credit" for rows apportioned to 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot (low): with apportioned per-row credits, a row with a real but sub-credit dollarCost can legitimately apportion to 0 credits once a sibling row absorbs the shared rounding remainder — rendering a flat "0 credits" reads as if nothing was charged, inconsistent with formatCreditCost's "<1 credit" wording used elsewhere in billing. Added dollarCost to the wire response (needed to distinguish a genuinely free row from a rounded-to-zero one) and a small formatRowCredits helper that only changes the label, not the underlying creditCost number, so the page-total reconciliation from the prior fix is unaffected. --- apps/sim/app/api/users/me/usage-logs/route.test.ts | 1 + apps/sim/app/api/users/me/usage-logs/route.ts | 1 + .../credit-usage-section/credit-usage-section.tsx | 14 +++++++++++++- apps/sim/lib/api/contracts/user.ts | 10 +++++++++- 4 files changed, 24 insertions(+), 2 deletions(-) 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 e8fc6c10c28..f6de3469001 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 @@ -53,6 +53,7 @@ describe('GET /api/users/me/usage-logs', () => { source: 'workflow', description: 'gpt-4o', creditCost: 100, + dollarCost: 0.5, }, ]) expect(body.summary).toEqual({ 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 7fa94c6bf5d..724e0983179 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -56,6 +56,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { source: log.source, description: log.description, creditCost: creditsByLogId[log.id], + dollarCost: log.cost, })) const bySourceCredits = Object.fromEntries( 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 6b1b2024152..8f8953f0393 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 @@ -36,6 +36,18 @@ const SOURCE_LABELS: Record = { enrichment: 'Enrichment', } +/** + * `creditCost` is apportioned across the page so row credits sum exactly to + * the page total (see route.ts) — a row can legitimately apportion to 0 + * credits despite a real, positive `dollarCost` once a sibling row absorbs + * the shared rounding remainder. Falls back to `formatCreditCost`'s "<1 + * credit" wording in that case instead of a flat, misleading "0 credits". + */ +function formatRowCredits(creditCost: number, dollarCost: number): string { + if (creditCost > 0) return formatCreditsLabel(creditCost) + return dollarCost > 0 ? '<1 credit' : '0 credits' +} + interface UsageLogRowProps { log: UsageLogEntry } @@ -53,7 +65,7 @@ function UsageLogRow({ log }: UsageLogRowProps) { {SOURCE_LABELS[log.source]} - {formatCreditsLabel(log.creditCost)} + {formatRowCredits(log.creditCost, log.dollarCost)}
) diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 3d4c0d79532..27496501813 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -291,8 +291,16 @@ export const usageLogEntrySchema = z.object({ createdAt: z.string(), source: usageLogSourceSchema, description: z.string(), - /** Credit-denominated cost of this event (Sim's usage unit; 1,000 credits = $5). */ + /** + * 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({ From fa73ea21f18d060156e17f42b9077d3c09c08694 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 15:44:51 -0700 Subject: [PATCH 04/17] fix(audit-logs): fix broken Custom range picker, trim time-range presets Custom range silently did nothing: the time-range trigger was a ChipSelect (Radix DropdownMenu, modal by default), and selecting "Custom range" opened the Calendar popover in the same tick the modal menu began its close/focus-lock cleanup, trapping the popover non-interactive. Swapped to ChipCombobox (Radix Popover, non-modal), mirroring the already-working pattern in the main Logs page exactly. Also trimmed the preset list from 11 to 8 entries (dropped Past 30 minutes/12 hours/14 days) so the menu fits without scrolling. --- apps/sim/ee/audit-logs/components/audit-logs.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx index 349eb829328..e4644da0e77 100644 --- a/apps/sim/ee/audit-logs/components/audit-logs.tsx +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -5,6 +5,7 @@ import { Badge, Button, Calendar, + ChipCombobox, ChipInput, ChipSelect, type ComboboxOption, @@ -32,16 +33,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' }, ] @@ -385,12 +384,18 @@ export function AuditLogs() { align='start' />
- {timeDisplayLabel} + } maxHeight={320} align='start' /> From 3c373919c9bdf0a9b0baad088f93a854850df4b9 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 15:45:12 -0700 Subject: [PATCH 05/17] feat(billing): dedicated Credit usage page with date-range filter and CSV export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #5391 per team feedback in Slack: move the credit usage list out of the inline Billing section into its own page, redesign rows to show source ("Chat", "Workflow: ") instead of a raw model description + badge, and add real date-range filtering and export. - Billing settings now shows a compact glance (30-day total + a "View usage logs" link) instead of the full inline list. - New /settings/billing/credit-usage page (sibling of [section], mirrors the secrets/[credentialId] detail-route pattern) with day presets (Today/7d/30d/All time) plus a working Custom range picker — the same ChipCombobox+Popover+Calendar wiring the audit-logs fix in this branch uses, not the broken ChipSelect pattern. - Rows show the humanized source label, or "Workflow: " for workflow-sourced events (new server-side workflow-name lookup, batched per page). Dropped the redundant badge and raw model description. - CSV export of the currently-filtered logs via a new GET .../usage-logs/export route (mode: 'text' contract, synchronous single-response CSV — the dataset is a bounded per-user ledger, not a workspace-wide export, so no async job queue needed). Query-filter logic (date-range resolution, workflow-name lookup) is shared with the list route via shared.ts rather than duplicated. - period/startDate/endDate live in the URL via a co-located search-params.ts; the list query keeps keepPreviousData + isPlaceholderData dimming during filter transitions, matching the behavior already shipped in #5391. Verified live end-to-end: back link navigation, custom range picker opens and applies, day presets, CSV export downloads and matches the on-screen rows exactly (credits reconcile with the total), compact Billing summary + link. --- .../users/me/usage-logs/export/route.test.ts | 159 +++++++++++ .../api/users/me/usage-logs/export/route.ts | 120 ++++++++ .../app/api/users/me/usage-logs/route.test.ts | 40 ++- apps/sim/app/api/users/me/usage-logs/route.ts | 22 +- .../sim/app/api/users/me/usage-logs/shared.ts | 57 ++++ .../credit-usage/credit-usage-view.tsx | 260 ++++++++++++++++++ .../settings/billing/credit-usage/loading.tsx | 28 ++ .../settings/billing/credit-usage/page.tsx | 27 ++ .../billing/credit-usage/search-params.ts | 23 ++ .../settings/components/billing/billing.tsx | 2 +- .../credit-usage-section.tsx | 159 ++--------- .../components/billing/search-params.ts | 21 -- apps/sim/hooks/queries/usage-logs.ts | 45 ++- .../sim/hooks/queries/utils/usage-log-keys.ts | 17 +- apps/sim/lib/api/contracts/user.ts | 62 ++++- 15 files changed, 849 insertions(+), 193 deletions(-) create mode 100644 apps/sim/app/api/users/me/usage-logs/export/route.test.ts create mode 100644 apps/sim/app/api/users/me/usage-logs/export/route.ts create mode 100644 apps/sim/app/api/users/me/usage-logs/shared.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/billing/search-params.ts 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..b87362215a1 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/export/route.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserUsageLogs } = vi.hoisted(() => ({ + mockGetUserUsageLogs: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getUserUsageLogs: mockGetUserUsageLogs, +})) + +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' } }) + dbChainMockFns.where.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 }, + }) + + 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(header).toBe('Date,Type,Credits,Dollar cost') + expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100,0.5') + }) + + 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', + }, + ], + summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, + pagination: { hasMore: false }, + }) + dbChainMockFns.where.mockResolvedValueOnce([{ id: 'wf-1', name: 'ITSM_Prod_main' }]) + + 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', + }, + ], + summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, + pagination: { hasMore: false }, + }) + dbChainMockFns.where.mockResolvedValueOnce([{ id: 'wf-1', name: 'Prod, main' }]) + + 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' }) + ) + expect(csv.split('\n')).toHaveLength(3) // header + 2 rows + }) + + 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..c18f80d9250 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -0,0 +1,120 @@ +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 { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' +import { apportionCredits } from '@/lib/billing/credits/conversion' +import { neutralizeCsvFormula } from '@/lib/core/utils/csv' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveDateRange, resolveWorkflowNames } from '@/app/api/users/me/usage-logs/shared' + +const logger = createLogger('UsageLogsExportAPI') + +/** Safety cap on export size — a single user's credit ledger; not expected to approach this. */ +const MAX_EXPORT_ROWS = 5000 +const EXPORT_PAGE_SIZE = 500 + +const CSV_HEADER = ['Date', 'Type', 'Credits', 'Dollar cost'].join(',') + +function escapeCsvField(value: string | number): string { + const str = typeof value === 'string' ? neutralizeCsvFormula(value) : String(value) + return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str +} + +/** + * Humanized labels for `usage_log.source`, mirroring the Credit usage page's + * row rendering so the export reads identically to what's on screen. + */ +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', +} + +/** + * Downloads every usage log matching the current filter as CSV — unlike the + * paginated list route, this fetches up to `MAX_EXPORT_ROWS` 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 rows: Awaited>['logs'] = [] + let cursor: string | undefined + while (rows.length <= MAX_EXPORT_ROWS) { + const page = await getUserUsageLogs(auth.userId, { + source: source as UsageLogSource | undefined, + workspaceId, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + limit: EXPORT_PAGE_SIZE, + cursor, + }) + rows.push(...page.logs) + if (!page.pagination.hasMore) break + cursor = page.pagination.nextCursor + } + + if (rows.length > MAX_EXPORT_ROWS) { + logger.warn('Usage log export truncated at safety cap', { + userId: auth.userId, + period, + rowCount: rows.length, + cap: MAX_EXPORT_ROWS, + }) + } + const exportedRows = rows.slice(0, MAX_EXPORT_ROWS) + + const workflowNames = await resolveWorkflowNames(exportedRows) + + // Apportioned across the full export (not per-page) so every row's credits + // sum exactly to the export's own total — see route.ts's identical rationale. + const creditsByLogId = apportionCredits( + exportedRows.map((log) => ({ key: log.id, dollars: log.cost })) + ) + + const csvLines = exportedRows.map((log) => { + const workflowName = log.workflowId ? workflowNames.get(log.workflowId) : undefined + const type = + log.source === 'workflow' && workflowName + ? `Workflow: ${workflowName}` + : SOURCE_LABELS[log.source] + return [ + escapeCsvField(log.createdAt), + escapeCsvField(type), + escapeCsvField(creditsByLogId[log.id]), + escapeCsvField(log.cost), + ].join(',') + }) + + 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: exportedRows.length }) + + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-cache', + }, + }) +}) 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 f6de3469001..cbae6b162a2 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 @@ -1,13 +1,15 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetUserUsageLogs } = vi.hoisted(() => ({ mockGetUserUsageLogs: vi.fn(), })) +vi.mock('@sim/db', () => dbChainMock) + vi.mock('@/lib/billing/core/usage-log', () => ({ getUserUsageLogs: mockGetUserUsageLogs, })) @@ -18,6 +20,7 @@ describe('GET /api/users/me/usage-logs', () => { beforeEach(() => { vi.clearAllMocks() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + dbChainMockFns.where.mockResolvedValue([]) mockGetUserUsageLogs.mockResolvedValue({ logs: [ { @@ -51,7 +54,7 @@ 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, }, @@ -62,6 +65,39 @@ describe('GET /api/users/me/usage-logs', () => { }) }) + it('resolves 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', + }, + ], + summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, + pagination: { hasMore: false }, + }) + dbChainMockFns.where.mockResolvedValueOnce([{ id: 'wf-1', name: 'ITSM_Prod_main' }]) + + 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 () => { // Each row costs $0.002 (0.4 credits) — independent per-row rounding // would floor every row to 0 credits while the combined $0.006 rounds to 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 724e0983179..c7443ec3f4b 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -6,18 +6,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveDateRange, resolveWorkflowNames } 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,13 +22,15 @@ 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 } = parsed.data.query + + const dateRange = resolveDateRange(period, startDate, endDate) const result = await getUserUsageLogs(auth.userId, { source: source as UsageLogSource | undefined, workspaceId, - startDate: resolveStartDate(period), - endDate: new Date(), + startDate: dateRange.startDate, + endDate: dateRange.endDate, limit, cursor, }) @@ -50,11 +44,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { result.logs.map((log) => ({ key: log.id, dollars: log.cost })) ) + const workflowNames = await resolveWorkflowNames(result.logs) + const logs = result.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, source: log.source, - description: log.description, + workflowName: log.workflowId ? (workflowNames.get(log.workflowId) ?? null) : null, creditCost: creditsByLogId[log.id], dollarCost: log.cost, })) 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..21b7d10a2f3 --- /dev/null +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -0,0 +1,57 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { inArray } from 'drizzle-orm' +import type { UsageLogPeriod } from '@/lib/api/contracts/user' +import type { UsageLogSource } from '@/lib/billing/core/usage-log' + +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') { + // Contract-enforced: startDate is required whenever period is 'custom'. + return { + startDate: new Date(customStartDate as string), + 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() } +} + +/** + * Looks up workflow names for the distinct `workflowId`s among workflow-sourced + * logs, so rows can show "Workflow: {name}" instead of the generic source label. + * Shared by the list and export routes. + */ +export async function resolveWorkflowNames( + logs: { source: UsageLogSource; workflowId?: string }[] +): Promise> { + const workflowIds = [ + ...new Set( + logs + .filter((log) => log.source === 'workflow' && log.workflowId) + .map((log) => log.workflowId as string) + ), + ] + if (workflowIds.length === 0) return new Map() + + const rows = await db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(inArray(workflow.id, workflowIds)) + + return new Map(rows.map((row) => [row.id, row.name])) +} 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..57fd13f1f05 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/credit-usage-view.tsx @@ -0,0 +1,260 @@ +'use client' + +import { useRef, useState } from 'react' +import { + Calendar, + Chip, + ChipCombobox, + ChipLink, + type ComboboxOption, + chipVariants, + cn, + Popover, + PopoverAnchor, + PopoverContent, +} from '@sim/emcn' +import { ArrowLeft, Download } from '@sim/emcn/icons' +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 { 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' }, +] + +/** + * 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', +} + +/** 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 SOURCE_LABELS[log.source] +} + +/** + * `creditCost` is apportioned across the page so row credits sum exactly to + * the page total (see route.ts) — a row can legitimately apportion to 0 + * credits despite a real, positive `dollarCost` once a sibling row absorbs + * the shared rounding remainder. Falls back to `formatCreditCost`'s "<1 + * credit" wording in that case instead of a flat, misleading "0 credits". + */ +function formatRowCredits(creditCost: number, dollarCost: number): string { + if (creditCost > 0) return formatCreditsLabel(creditCost) + return dollarCost > 0 ? '<1 credit' : '0 credits' +} + +interface UsageLogRowProps { + log: UsageLogEntry +} + +function UsageLogRow({ log }: UsageLogRowProps) { + return ( +
+ + {formatDateTime(new Date(log.createdAt))} + + + {rowLabel(log)} + + + {formatRowCredits(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 dateRangeAppliedRef = useRef(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) => { + dateRangeAppliedRef.current = true + setFilters({ period: 'custom', startDate: nextStart, endDate: nextEnd }) + setDatePickerOpen(false) + } + + const handleDatePickerCancel = () => { + setDatePickerOpen(false) + } + + /** + * Downloads a CSV of every log matching the current filter — a plain anchor + * navigation to the export route, not a `fetch`, so the browser handles the + * download natively via the response's `Content-Disposition` header. + */ + const handleExport = () => { + const params = new URLSearchParams({ period }) + if (period === 'custom' && startDate && endDate) { + params.set('startDate', startDate) + params.set('endDate', endDate) + } + const link = document.createElement('a') + link.href = `/api/users/me/usage-logs/export?${params.toString()}` + document.body.appendChild(link) + link.click() + link.remove() + } + + 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={ + + Export + + } + > +
+

Credit usage

+

+ Every credit-consuming event behind your usage. +

+
+ +
+ + Total: {formatCreditsLabel(totalCredits)} + +
+ {periodDisplayLabel} + } + /> + { + if (!isOpen) { + if (dateRangeAppliedRef.current) { + dateRangeAppliedRef.current = false + } else { + 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..13172328e8b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx @@ -0,0 +1,28 @@ +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..54f4f00d304 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx @@ -0,0 +1,27 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +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. + */ +export default async function CreditUsagePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + 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..87d58b92578 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/search-params.ts @@ -0,0 +1,23 @@ +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'`. + */ +export const creditUsageParsers = { + period: parseAsStringLiteral(usageLogPeriodSchema.options).withDefault('30d'), + startDate: parseAsString.withDefault(''), + endDate: parseAsString.withDefault(''), +} 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 8f8953f0393..7f8154e7c7e 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,152 +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', -} - -/** - * `creditCost` is apportioned across the page so row credits sum exactly to - * the page total (see route.ts) — a row can legitimately apportion to 0 - * credits despite a real, positive `dollarCost` once a sibling row absorbs - * the shared rounding remainder. Falls back to `formatCreditCost`'s "<1 - * credit" wording in that case instead of a flat, misleading "0 credits". - */ -function formatRowCredits(creditCost: number, dollarCost: number): string { - if (creditCost > 0) return formatCreditsLabel(creditCost) - return dollarCost > 0 ? '<1 credit' : '0 credits' -} - -interface UsageLogRowProps { - log: UsageLogEntry -} - -function UsageLogRow({ log }: UsageLogRowProps) { - return ( -
- - {formatDateTime(new Date(log.createdAt))} - - - {log.description} - - - {SOURCE_LABELS[log.source]} - - - {formatRowCredits(log.creditCost, log.dollarCost)} - -
- ) +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, - isPlaceholderData, - 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 } = useUsageSummary(SUMMARY_PERIOD) return ( - setFilters({ period: value as UsageLogPeriod })} - /> - } - > - {/* isPlaceholderData means these rows/total are the still-displayed prior - period's data while the newly selected period is in flight — dim them - so they don't read as settled results for the period now shown in the - dropdown. */} -
- {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 ? '—' : 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/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts index aabd57c6cdb..58626313d90 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,41 @@ 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 ): Promise { return requestJson(getUsageLogsContract, { - query: { period, limit: PAGE_SIZE, cursor }, + query: { ...filter, limit, cursor }, 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 +54,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), + 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/user.ts b/apps/sim/lib/api/contracts/user.ts index 27496501813..68eeff49b14 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -276,21 +276,55 @@ export const usageLogSourceSchema = z.enum([ 'enrichment', ]) -export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all']) - -export const usageLogsQuerySchema = z.object({ +export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all', 'custom']) + +/** + * `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'. */ +function requireStartDateForCustomPeriod< + Schema extends z.ZodType<{ period?: string; startDate?: string }>, +>(schema: Schema) { + return schema.refine((query) => query.period !== 'custom' || query.startDate !== undefined, { + error: 'startDate is required when period is "custom"', + path: ['startDate'], + }) +} + +export const usageLogsQuerySchema = requireStartDateForCustomPeriod( + usageLogsFilterSchema.extend({ + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), + }) +) + +/** Same filters as the list query, without pagination — the export route returns every match. */ +export const exportUsageLogsQuerySchema = requireStartDateForCustomPeriod(usageLogsFilterSchema) + export const usageLogEntrySchema = z.object({ id: z.string(), createdAt: z.string(), source: usageLogSourceSchema, - description: z.string(), + /** 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 @@ -326,10 +360,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'), From 762b34dbd847065701de242e65df23c873231d4a Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 16:03:45 -0700 Subject: [PATCH 06/17] refactor(billing): move workflow-name enrichment into getUserUsageLogs, dedup helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify pass over the credit-usage-page branch (4 parallel review angles: reuse, simplification, efficiency, altitude): - getUserUsageLogs now LEFT JOINs workflow and returns workflowName directly (matching lib/logs/list-logs.ts's established pattern), eliminating the route-layer resolveWorkflowNames query that both the list and export routes previously ran independently. - Added includeSummary (default true) to getUserUsageLogs so the export route's cursor loop can skip the cursor-independent SUM/GROUP BY aggregate it never reads — that aggregate was being recomputed on every page of a paginated export for no reason. - Fixed an off-by-one in the export's pagination loop: `<= MAX_EXPORT_ROWS` let it fetch one more full page past the cap only to discard it; `< MAX_EXPORT_ROWS` with a shrinking per-page limit never overshoots. - Deduplicated the SOURCE_LABELS map (was defined identically in both the page and the export route) into a shared, DB-free source-labels.ts both can import. - Export route now builds CSV rows via lib/table/export-format.ts's toCsvRow/formatCsvValue instead of a hand-rolled escaper. - Added formatApportionedCreditCost to conversion.ts so the page's row rendering shares its zero/sub-credit wording with formatCreditCost instead of re-deriving the same three-way branch. - Replaced the generic requireStartDateForCustomPeriod contract helper (nontrivial generic bound for a single four-line refine used at two call sites) with a plain shared error-options object. - Removed the credit-usage page's dateRangeAppliedRef guard — a controlled Radix Popover never re-invokes onOpenChange in response to the parent's own setState call, so the guard was defending against a re-entrant close that can't happen. - Added a modal prop to ChipSelect (forwarded to the underlying DropdownMenu, which already supported it) so a future call site that hits the same "modal select traps a same-tick Popover" bug the audit-logs Custom range fix worked around has a real fix available instead of having to swap components again. Re-verified live end-to-end after the refactor: workflow-name resolution, credit reconciliation, and CSV export all still correct. --- .../users/me/usage-logs/export/route.test.ts | 45 +++++++++-- .../api/users/me/usage-logs/export/route.ts | 68 +++++----------- .../app/api/users/me/usage-logs/route.test.ts | 9 +-- apps/sim/app/api/users/me/usage-logs/route.ts | 6 +- .../sim/app/api/users/me/usage-logs/shared.ts | 29 ------- .../api/users/me/usage-logs/source-labels.ts | 20 +++++ .../credit-usage/credit-usage-view.tsx | 49 ++--------- apps/sim/lib/api/contracts/user.ts | 24 +++--- apps/sim/lib/billing/core/usage-log.ts | 81 ++++++++++++++----- apps/sim/lib/billing/credits/conversion.ts | 14 ++++ .../components/chip-select/chip-select.tsx | 10 +++ 11 files changed, 189 insertions(+), 166 deletions(-) create mode 100644 apps/sim/app/api/users/me/usage-logs/source-labels.ts 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 index b87362215a1..9daa8773f51 100644 --- 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 @@ -1,15 +1,13 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetUserUsageLogs } = vi.hoisted(() => ({ mockGetUserUsageLogs: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/usage-log', () => ({ getUserUsageLogs: mockGetUserUsageLogs, })) @@ -20,7 +18,6 @@ describe('GET /api/users/me/usage-logs/export', () => { beforeEach(() => { vi.clearAllMocks() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - dbChainMockFns.where.mockResolvedValue([]) }) it('returns 401 when unauthenticated', async () => { @@ -57,6 +54,21 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100,0.5') }) + 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: [ @@ -68,12 +80,12 @@ describe('GET /api/users/me/usage-logs/export', () => { description: 'execution_fee', cost: 0.01, workflowId: 'wf-1', + workflowName: 'ITSM_Prod_main', }, ], summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, pagination: { hasMore: false }, }) - dbChainMockFns.where.mockResolvedValueOnce([{ id: 'wf-1', name: 'ITSM_Prod_main' }]) const response = await GET(createMockRequest('GET')) const csv = await response.text() @@ -92,12 +104,12 @@ describe('GET /api/users/me/usage-logs/export', () => { description: 'execution_fee', cost: 0.01, workflowId: 'wf-1', + workflowName: 'Prod, main', }, ], summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, pagination: { hasMore: false }, }) - dbChainMockFns.where.mockResolvedValueOnce([{ id: 'wf-1', name: 'Prod, main' }]) const response = await GET(createMockRequest('GET')) const csv = await response.text() @@ -148,6 +160,27 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(csv.split('\n')).toHaveLength(3) // header + 2 rows }) + it('stops at exactly the row cap without an extra wasted page fetch', async () => { + // MAX_EXPORT_ROWS=5000, EXPORT_PAGE_SIZE=500 divide evenly — a naive + // `<=` loop bound would issue one more page fetch (discarded afterward) + // once the cap is hit exactly. Landing on the cap in a single mocked + // page with hasMore:true still true asserts the loop doesn't re-enter. + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: Array.from({ length: 5000 }, (_, 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-4999' }, + }) + + 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') 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 index c18f80d9250..ed9f34f2dd8 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -5,9 +5,10 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' import { apportionCredits } from '@/lib/billing/credits/conversion' -import { neutralizeCsvFormula } from '@/lib/core/utils/csv' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveDateRange, resolveWorkflowNames } from '@/app/api/users/me/usage-logs/shared' +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') @@ -15,28 +16,7 @@ const logger = createLogger('UsageLogsExportAPI') const MAX_EXPORT_ROWS = 5000 const EXPORT_PAGE_SIZE = 500 -const CSV_HEADER = ['Date', 'Type', 'Credits', 'Dollar cost'].join(',') - -function escapeCsvField(value: string | number): string { - const str = typeof value === 'string' ? neutralizeCsvFormula(value) : String(value) - return /[",\n]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str -} - -/** - * Humanized labels for `usage_log.source`, mirroring the Credit usage page's - * row rendering so the export reads identically to what's on screen. - */ -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', -} +const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits', 'Dollar cost']) /** * Downloads every usage log matching the current filter as CSV — unlike the @@ -58,56 +38,52 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const rows: Awaited>['logs'] = [] let cursor: string | undefined - while (rows.length <= MAX_EXPORT_ROWS) { + let truncated = false + while (rows.length < MAX_EXPORT_ROWS) { const page = await getUserUsageLogs(auth.userId, { source: source as UsageLogSource | undefined, workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, - limit: EXPORT_PAGE_SIZE, + limit: Math.min(EXPORT_PAGE_SIZE, MAX_EXPORT_ROWS - rows.length), cursor, + includeSummary: false, }) rows.push(...page.logs) if (!page.pagination.hasMore) break + truncated = rows.length >= MAX_EXPORT_ROWS cursor = page.pagination.nextCursor } - if (rows.length > MAX_EXPORT_ROWS) { + if (truncated) { logger.warn('Usage log export truncated at safety cap', { userId: auth.userId, period, - rowCount: rows.length, cap: MAX_EXPORT_ROWS, }) } - const exportedRows = rows.slice(0, MAX_EXPORT_ROWS) - - const workflowNames = await resolveWorkflowNames(exportedRows) // Apportioned across the full export (not per-page) so every row's credits // sum exactly to the export's own total — see route.ts's identical rationale. - const creditsByLogId = apportionCredits( - exportedRows.map((log) => ({ key: log.id, dollars: log.cost })) - ) + const creditsByLogId = apportionCredits(rows.map((log) => ({ key: log.id, dollars: log.cost }))) - const csvLines = exportedRows.map((log) => { - const workflowName = log.workflowId ? workflowNames.get(log.workflowId) : undefined + const csvLines = rows.map((log) => { const type = - log.source === 'workflow' && workflowName - ? `Workflow: ${workflowName}` - : SOURCE_LABELS[log.source] - return [ - escapeCsvField(log.createdAt), - escapeCsvField(type), - escapeCsvField(creditsByLogId[log.id]), - escapeCsvField(log.cost), - ].join(',') + 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]), + formatCsvValue(log.cost), + ]) }) 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: exportedRows.length }) + logger.info('Exported usage logs', { userId: auth.userId, period, rowCount: rows.length }) return new NextResponse(csv, { status: 200, 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 cbae6b162a2..ef5568a49c5 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 @@ -1,15 +1,13 @@ /** * @vitest-environment node */ -import { authMockFns, createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetUserUsageLogs } = vi.hoisted(() => ({ mockGetUserUsageLogs: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/usage-log', () => ({ getUserUsageLogs: mockGetUserUsageLogs, })) @@ -20,7 +18,6 @@ describe('GET /api/users/me/usage-logs', () => { beforeEach(() => { vi.clearAllMocks() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - dbChainMockFns.where.mockResolvedValue([]) mockGetUserUsageLogs.mockResolvedValue({ logs: [ { @@ -65,7 +62,7 @@ describe('GET /api/users/me/usage-logs', () => { }) }) - it('resolves the workflow name for workflow-sourced rows', async () => { + it('passes through the workflow name for workflow-sourced rows', async () => { mockGetUserUsageLogs.mockResolvedValue({ logs: [ { @@ -76,12 +73,12 @@ describe('GET /api/users/me/usage-logs', () => { description: 'execution_fee', cost: 0.01, workflowId: 'wf-1', + workflowName: 'ITSM_Prod_main', }, ], summary: { totalCost: 0.01, bySource: { workflow: 0.01 } }, pagination: { hasMore: false }, }) - dbChainMockFns.where.mockResolvedValueOnce([{ id: 'wf-1', name: 'ITSM_Prod_main' }]) const response = await GET(createMockRequest('GET')) const body = await response.json() 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 c7443ec3f4b..70841aeffc9 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveDateRange, resolveWorkflowNames } from '@/app/api/users/me/usage-logs/shared' +import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' const logger = createLogger('UsageLogsAPI') @@ -44,13 +44,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { result.logs.map((log) => ({ key: log.id, dollars: log.cost })) ) - const workflowNames = await resolveWorkflowNames(result.logs) - const logs = result.logs.map((log) => ({ id: log.id, createdAt: log.createdAt, source: log.source, - workflowName: log.workflowId ? (workflowNames.get(log.workflowId) ?? null) : null, + workflowName: log.workflowName ?? null, creditCost: creditsByLogId[log.id], dollarCost: log.cost, })) diff --git a/apps/sim/app/api/users/me/usage-logs/shared.ts b/apps/sim/app/api/users/me/usage-logs/shared.ts index 21b7d10a2f3..d7508448a91 100644 --- a/apps/sim/app/api/users/me/usage-logs/shared.ts +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -1,8 +1,4 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' -import { inArray } from 'drizzle-orm' import type { UsageLogPeriod } from '@/lib/api/contracts/user' -import type { UsageLogSource } from '@/lib/billing/core/usage-log' const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 } @@ -30,28 +26,3 @@ export function resolveDateRange( startDate.setDate(startDate.getDate() - PERIOD_TO_DAYS[period]) return { startDate, endDate: new Date() } } - -/** - * Looks up workflow names for the distinct `workflowId`s among workflow-sourced - * logs, so rows can show "Workflow: {name}" instead of the generic source label. - * Shared by the list and export routes. - */ -export async function resolveWorkflowNames( - logs: { source: UsageLogSource; workflowId?: string }[] -): Promise> { - const workflowIds = [ - ...new Set( - logs - .filter((log) => log.source === 'workflow' && log.workflowId) - .map((log) => log.workflowId as string) - ), - ] - if (workflowIds.length === 0) return new Map() - - const rows = await db - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(inArray(workflow.id, workflowIds)) - - return new Map(rows.map((row) => [row.id, row.name])) -} 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 index 57fd13f1f05..48ff079a802 100644 --- 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 @@ -1,6 +1,6 @@ 'use client' -import { useRef, useState } from 'react' +import { useState } from 'react' import { Calendar, Chip, @@ -16,8 +16,9 @@ import { import { ArrowLeft, Download } from '@sim/emcn/icons' 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 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 { @@ -35,38 +36,10 @@ const PERIOD_OPTIONS: ComboboxOption[] = [ { value: 'custom', label: 'Custom range' }, ] -/** - * 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', -} - /** 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 SOURCE_LABELS[log.source] -} - -/** - * `creditCost` is apportioned across the page so row credits sum exactly to - * the page total (see route.ts) — a row can legitimately apportion to 0 - * credits despite a real, positive `dollarCost` once a sibling row absorbs - * the shared rounding remainder. Falls back to `formatCreditCost`'s "<1 - * credit" wording in that case instead of a flat, misleading "0 credits". - */ -function formatRowCredits(creditCost: number, dollarCost: number): string { - if (creditCost > 0) return formatCreditsLabel(creditCost) - return dollarCost > 0 ? '<1 credit' : '0 credits' + return USAGE_LOG_SOURCE_LABELS[log.source] } interface UsageLogRowProps { @@ -83,7 +56,7 @@ function UsageLogRow({ log }: UsageLogRowProps) { {rowLabel(log)} - {formatRowCredits(log.creditCost, log.dollarCost)} + {formatApportionedCreditCost(log.creditCost, log.dollarCost)}
) @@ -101,7 +74,6 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { creditUsageUrlKeys ) const [datePickerOpen, setDatePickerOpen] = useState(false) - const dateRangeAppliedRef = useRef(false) const handlePeriodChange = (value: string) => { if (value === 'custom') { @@ -112,7 +84,6 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { } const handleDateRangeApply = (nextStart: string, nextEnd: string) => { - dateRangeAppliedRef.current = true setFilters({ period: 'custom', startDate: nextStart, endDate: nextEnd }) setDatePickerOpen(false) } @@ -197,13 +168,7 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { { - if (!isOpen) { - if (dateRangeAppliedRef.current) { - dateRangeAppliedRef.current = false - } else { - handleDatePickerCancel() - } - } + if (!isOpen) handleDatePickerCancel() }} > diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index 68eeff49b14..bdce7b23883 100644 --- a/apps/sim/lib/api/contracts/user.ts +++ b/apps/sim/lib/api/contracts/user.ts @@ -300,24 +300,26 @@ const usageLogsFilterSchema = z.object({ }) /** Both the list and export query schemas require startDate whenever period is 'custom'. */ -function requireStartDateForCustomPeriod< - Schema extends z.ZodType<{ period?: string; startDate?: string }>, ->(schema: Schema) { - return schema.refine((query) => query.period !== 'custom' || query.startDate !== undefined, { - error: 'startDate is required when period is "custom"', - path: ['startDate'], - }) +const startDateRequiredForCustomPeriod = { + error: 'startDate is required when period is "custom"', + path: ['startDate'], } -export const usageLogsQuerySchema = requireStartDateForCustomPeriod( - usageLogsFilterSchema.extend({ +export const usageLogsQuerySchema = usageLogsFilterSchema + .extend({ limit: z.coerce.number().min(1).max(100).optional().default(50), cursor: z.string().optional(), }) -) + .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 = requireStartDateForCustomPeriod(usageLogsFilterSchema) +export const exportUsageLogsQuerySchema = usageLogsFilterSchema.refine( + (query) => query.period !== 'custom' || query.startDate !== undefined, + startDateRequiredForCustomPeriod +) export const usageLogEntrySchema = z.object({ id: z.string(), diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 034609a65f3..ef19c6ddd6b 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -1,6 +1,6 @@ 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' @@ -533,6 +533,14 @@ export interface GetUsageLogsOptions { limit?: number /** Cursor for pagination (log ID) */ cursor?: string + /** + * 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 +556,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 +566,7 @@ interface UsageLogEntry { */ export interface UsageLogsResult { logs: UsageLogEntry[] + /** `{ totalCost: 0, bySource: {} }` when `includeSummary` is `false`. */ summary: { totalCost: number bySource: Record @@ -573,7 +584,15 @@ 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, + includeSummary = true, + } = options try { const conditions = [eq(usageLog.userId, userId)] @@ -612,8 +631,23 @@ export async function getUserUsageLogs( } 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) + // Left join so a since-deleted workflow (workflowId still set, no + // matching row) doesn't drop the usage_log row — workflowName is just null. + .leftJoin(workflow, eq(usageLog.workflowId, workflow.id)) .where(and(...conditions)) .orderBy(desc(usageLog.createdAt), desc(usageLog.id)) .limit(limit + 1) @@ -631,31 +665,34 @@ 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 summaryResult = await dbReplica - .select({ - source: usageLog.source, - totalCost: sql`SUM(${usageLog.cost})`, - }) - .from(usageLog) - .where(and(...summaryConditions)) - .groupBy(usageLog.source) - const bySource: Record = {} let totalCost = 0 - for (const row of summaryResult) { - const sourceCost = Number.parseFloat(row.totalCost || '0') - bySource[row.source] = sourceCost - totalCost += sourceCost + if (includeSummary) { + 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 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 + } } 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('') }} From 46beb8a9c93707dd1ae7cab3fcd3320e583aaac8 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 16:13:18 -0700 Subject: [PATCH 07/17] fix(billing): drop Dollar cost from the CSV export, strip inline comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We only surface credits to the user, not the underlying dollar figure — "Dollar cost" was the one place the export literally displayed a dollar amount (the rest of the codebase uses dollarCost purely as an internal signal to distinguish a sub-credit charge from a genuinely free event, never rendered as a "$" value). --- .../app/api/users/me/usage-logs/export/route.test.ts | 10 +++------- apps/sim/app/api/users/me/usage-logs/export/route.ts | 5 +---- apps/sim/app/api/users/me/usage-logs/route.test.ts | 4 ---- apps/sim/app/api/users/me/usage-logs/route.ts | 5 ----- apps/sim/app/api/users/me/usage-logs/shared.ts | 1 - apps/sim/lib/billing/core/usage-log.ts | 2 -- 6 files changed, 4 insertions(+), 23 deletions(-) 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 index 9daa8773f51..0a6c09628c1 100644 --- 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 @@ -50,8 +50,8 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(response.headers.get('Content-Type')).toBe('text/csv; charset=utf-8') expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=') - expect(header).toBe('Date,Type,Credits,Dollar cost') - expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100,0.5') + expect(header).toBe('Date,Type,Credits') + expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100') }) it('does not request the summary aggregate — the export never reads it', async () => { @@ -157,14 +157,10 @@ describe('GET /api/users/me/usage-logs/export', () => { 'user-1', expect.objectContaining({ cursor: 'log-1' }) ) - expect(csv.split('\n')).toHaveLength(3) // header + 2 rows + expect(csv.split('\n')).toHaveLength(3) }) it('stops at exactly the row cap without an extra wasted page fetch', async () => { - // MAX_EXPORT_ROWS=5000, EXPORT_PAGE_SIZE=500 divide evenly — a naive - // `<=` loop bound would issue one more page fetch (discarded afterward) - // once the cap is hit exactly. Landing on the cap in a single mocked - // page with hasMore:true still true asserts the loop doesn't re-enter. mockGetUserUsageLogs.mockResolvedValueOnce({ logs: Array.from({ length: 5000 }, (_, i) => ({ id: `log-${i}`, 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 index ed9f34f2dd8..51d9e011e16 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -16,7 +16,7 @@ const logger = createLogger('UsageLogsExportAPI') const MAX_EXPORT_ROWS = 5000 const EXPORT_PAGE_SIZE = 500 -const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits', 'Dollar cost']) +const CSV_HEADER = toCsvRow(['Date', 'Type', 'Credits']) /** * Downloads every usage log matching the current filter as CSV — unlike the @@ -63,8 +63,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - // Apportioned across the full export (not per-page) so every row's credits - // sum exactly to the export's own total — see route.ts's identical rationale. const creditsByLogId = apportionCredits(rows.map((log) => ({ key: log.id, dollars: log.cost }))) const csvLines = rows.map((log) => { @@ -76,7 +74,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { formatCsvValue(log.createdAt), formatCsvValue(type), formatCsvValue(creditsByLogId[log.id]), - formatCsvValue(log.cost), ]) }) 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 ef5568a49c5..28bd84f699d 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 @@ -96,10 +96,6 @@ describe('GET /api/users/me/usage-logs', () => { }) it('apportions row credits so they sum exactly to the page total, instead of rounding each row independently', async () => { - // Each row costs $0.002 (0.4 credits) — independent per-row rounding - // would floor every row to 0 credits while the combined $0.006 rounds to - // 1, visibly failing to add up. Apportioning must place that 1 credit on - // exactly one row so the displayed rows sum to the displayed total. mockGetUserUsageLogs.mockResolvedValue({ logs: [ { id: 'log-a', createdAt: '2026-07-01T00:00:00.000Z', source: 'workflow', cost: 0.002 }, 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 70841aeffc9..83f584552d8 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -35,11 +35,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { cursor, }) - // Apportioned (not independently rounded per row) so this page's visible - // credit costs always sum to exactly `dollarsToCredits(sum of this page's - // dollars)` — rounding each row on its own can drift from that sum by - // several credits over enough rows, which reads as "the numbers don't add - // up" next to the period total. const creditsByLogId = apportionCredits( result.logs.map((log) => ({ key: log.id, dollars: log.cost })) ) diff --git a/apps/sim/app/api/users/me/usage-logs/shared.ts b/apps/sim/app/api/users/me/usage-logs/shared.ts index d7508448a91..a77a9340275 100644 --- a/apps/sim/app/api/users/me/usage-logs/shared.ts +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -14,7 +14,6 @@ export function resolveDateRange( customEndDate: string | undefined ): ResolvedDateRange { if (period === 'custom') { - // Contract-enforced: startDate is required whenever period is 'custom'. return { startDate: new Date(customStartDate as string), endDate: customEndDate ? new Date(customEndDate) : new Date(), diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index ef19c6ddd6b..94795745539 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -645,8 +645,6 @@ export async function getUserUsageLogs( executionId: usageLog.executionId, }) .from(usageLog) - // Left join so a since-deleted workflow (workflowId still set, no - // matching row) doesn't drop the usage_log row — workflowName is just null. .leftJoin(workflow, eq(usageLog.workflowId, workflow.id)) .where(and(...conditions)) .orderBy(desc(usageLog.createdAt), desc(usageLog.id)) From 28712340fd1e5c9b4c60022e3cee6d78673a35b7 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 16:25:04 -0700 Subject: [PATCH 08/17] fix(billing): export honors partial custom date range, surfaces truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile (P1) and Cursor Bugbot independently caught the same bug: handleExport only forwarded startDate/endDate when BOTH were truthy, but the list query and both API contracts treat endDate as optional for a custom period (defaults to now). A user landing on a bookmarked ?period=custom&startDate=... URL would see populated rows and an enabled Export button, then get a 400 on click since the export omitted the required startDate too. Fixed by forwarding each date independently, matching the list query's existing behavior. Also addressed Greptile's other two findings: - The export route now sets X-Export-Truncated so a 5,000-row-capped download is visible to the user (a toast), not just a server log. Reading that header meant switching the trigger from a plain anchor navigation to fetch+blob — an anchor can't inspect the response before the browser commits to the download. - resolveDateRange now throws explicitly when a custom period is missing startDate instead of silencing the null check with `as string`, which would have produced a silent Invalid Date if ever called without prior contract validation. --- .../users/me/usage-logs/export/route.test.ts | 18 ++++++++ .../api/users/me/usage-logs/export/route.ts | 1 + .../api/users/me/usage-logs/shared.test.ts | 41 +++++++++++++++++++ .../sim/app/api/users/me/usage-logs/shared.ts | 3 +- .../credit-usage/credit-usage-view.tsx | 36 ++++++++++++---- 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 apps/sim/app/api/users/me/usage-logs/shared.test.ts 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 index 0a6c09628c1..1497e3da0c6 100644 --- 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 @@ -50,10 +50,28 @@ describe('GET /api/users/me/usage-logs/export', () => { 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 row cap is hit with more data remaining', async () => { + mockGetUserUsageLogs.mockResolvedValueOnce({ + logs: Array.from({ length: 5000 }, (_, 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-4999' }, + }) + + 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: [], 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 index 51d9e011e16..a78cb18c3dc 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -88,6 +88,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { '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/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 index a77a9340275..1c143248edc 100644 --- a/apps/sim/app/api/users/me/usage-logs/shared.ts +++ b/apps/sim/app/api/users/me/usage-logs/shared.ts @@ -14,8 +14,9 @@ export function resolveDateRange( customEndDate: string | undefined ): ResolvedDateRange { if (period === 'custom') { + if (!customStartDate) throw new Error('startDate is required when period is "custom"') return { - startDate: new Date(customStartDate as string), + startDate: new Date(customStartDate), endDate: customEndDate ? new Date(customEndDate) : new Date(), } } 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 index 48ff079a802..0922ffdcd95 100644 --- 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 @@ -12,6 +12,7 @@ import { Popover, PopoverAnchor, PopoverContent, + toast, } from '@sim/emcn' import { ArrowLeft, Download } from '@sim/emcn/icons' import { formatDateTime } from '@sim/utils/formatting' @@ -93,21 +94,38 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { } /** - * Downloads a CSV of every log matching the current filter — a plain anchor - * navigation to the export route, not a `fetch`, so the browser handles the - * download natively via the response's `Content-Disposition` header. + * 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 = () => { + const handleExport = async () => { const params = new URLSearchParams({ period }) - if (period === 'custom' && startDate && endDate) { - params.set('startDate', startDate) - params.set('endDate', endDate) + 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 = `/api/users/me/usage-logs/export?${params.toString()}` + 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 = @@ -140,7 +158,7 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { } actions={ - + void handleExport()} disabled={logs.length === 0}> Export } From d1dcf6ced540a76d948663609d78f44d64fcc6a2 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 16:51:27 -0700 Subject: [PATCH 09/17] fix(billing): remove the export's arbitrary row cap, fix a cursor pagination bug it exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A personal credit ledger doesn't have the same unbounded-growth problem a workspace table does — capping the export at 5,000 rows just meant long-tenured or high-usage accounts (exactly the ones most likely to need a full export to reconcile a billing question) got silently truncated. Replaced the cap with a 50,000-row circuit breaker that should never fire in normal use (logged as an error, not a warning, if it ever does) and bumped the page size from 500 to 1,000 to cut round trips. Removing the cap surfaced a real, pre-existing bug in getUserUsageLogs's cursor pagination: a raw `sql` template embedded a JS Date object directly as a bound parameter, which the postgres driver can't serialize (unlike drizzle's typed gte/lte operators, which already handle Date correctly elsewhere in the same function). It only ever manifested past the first page, which nothing before this export route's tight multi-page loop reliably exercised. Replaced the raw sql template with drizzle's typed lt/eq/or/and operators, matching the pattern already proven correct in this file. Verified live: seeded 6,000 rows (past the old cap) and confirmed the export downloads all of them in one request with credits reconciling exactly against the total. --- .../users/me/usage-logs/export/route.test.ts | 12 +++++----- .../api/users/me/usage-logs/export/route.ts | 23 +++++++++++-------- apps/sim/lib/billing/core/usage-log.ts | 9 +++++--- 3 files changed, 26 insertions(+), 18 deletions(-) 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 index 1497e3da0c6..535ce45a2f1 100644 --- 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 @@ -55,16 +55,16 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(row).toBe('2026-07-01T00:00:00.000Z,Chat,100') }) - it('sets X-Export-Truncated when the row cap is hit with more data remaining', async () => { + it('sets X-Export-Truncated when the safety cap is hit with more data remaining', async () => { mockGetUserUsageLogs.mockResolvedValueOnce({ - logs: Array.from({ length: 5000 }, (_, i) => ({ + 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-4999' }, + pagination: { hasMore: true, nextCursor: 'log-49999' }, }) const response = await GET(createMockRequest('GET')) @@ -178,16 +178,16 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(csv.split('\n')).toHaveLength(3) }) - it('stops at exactly the row cap without an extra wasted page fetch', async () => { + it('stops at exactly the safety cap without an extra wasted page fetch', async () => { mockGetUserUsageLogs.mockResolvedValueOnce({ - logs: Array.from({ length: 5000 }, (_, i) => ({ + 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-4999' }, + pagination: { hasMore: true, nextCursor: 'log-49999' }, }) await GET(createMockRequest('GET')) 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 index a78cb18c3dc..dc45205d589 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -12,15 +12,20 @@ import { USAGE_LOG_SOURCE_LABELS } from '@/app/api/users/me/usage-logs/source-la const logger = createLogger('UsageLogsExportAPI') -/** Safety cap on export size — a single user's credit ledger; not expected to approach this. */ -const MAX_EXPORT_ROWS = 5000 -const EXPORT_PAGE_SIZE = 500 +/** + * 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 up to `MAX_EXPORT_ROWS` in one response + * 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). */ @@ -39,27 +44,27 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const rows: Awaited>['logs'] = [] let cursor: string | undefined let truncated = false - while (rows.length < MAX_EXPORT_ROWS) { + while (rows.length < EXPORT_SAFETY_CAP) { const page = await getUserUsageLogs(auth.userId, { source: source as UsageLogSource | undefined, workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, - limit: Math.min(EXPORT_PAGE_SIZE, MAX_EXPORT_ROWS - rows.length), + limit: Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length), cursor, includeSummary: false, }) rows.push(...page.logs) if (!page.pagination.hasMore) break - truncated = rows.length >= MAX_EXPORT_ROWS + truncated = rows.length >= EXPORT_SAFETY_CAP cursor = page.pagination.nextCursor } if (truncated) { - logger.warn('Usage log export truncated at safety cap', { + logger.error('Usage log export hit the safety cap — investigate this account', { userId: auth.userId, period, - cap: MAX_EXPORT_ROWS, + cap: EXPORT_SAFETY_CAP, }) } diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 94795745539..ca5019471f8 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -4,7 +4,7 @@ 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 { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' @@ -624,9 +624,12 @@ export async function getUserUsageLogs( .limit(1) if (cursorLog.length > 0) { - conditions.push( - sql`(${usageLog.createdAt} < ${cursorLog[0].createdAt} OR (${usageLog.createdAt} = ${cursorLog[0].createdAt} AND ${usageLog.id} < ${cursor}))` + const cursorCreatedAt = cursorLog[0].createdAt + const cursorCondition = or( + lt(usageLog.createdAt, cursorCreatedAt), + and(eq(usageLog.createdAt, cursorCreatedAt), lt(usageLog.id, cursor)) ) + if (cursorCondition) conditions.push(cursorCondition) } } From 7f97f31ae8354f2edfcff5017bb023cbf75a2bb7 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 17:02:21 -0700 Subject: [PATCH 10/17] perf(billing): skip the redundant cursor lookup when the caller already has it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export loop holds the previous page's rows in memory, so its next cursor's createdAt is already known — getUserUsageLogs was still re-resolving it via an extra DB round trip every page regardless. Added an optional cursorCreatedAt to skip that lookup when provided; the list route's existing callers are unaffected since they don't pass it. Verified live: zero cursor-lookup queries fired across a 3,500-row / 4-page export that previously issued one per page. --- .../users/me/usage-logs/export/route.test.ts | 5 ++- .../api/users/me/usage-logs/export/route.ts | 4 +++ apps/sim/lib/billing/core/usage-log.ts | 35 ++++++++++++------- 3 files changed, 31 insertions(+), 13 deletions(-) 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 index 535ce45a2f1..f1bc5acb1d5 100644 --- 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 @@ -173,7 +173,10 @@ describe('GET /api/users/me/usage-logs/export', () => { expect(mockGetUserUsageLogs).toHaveBeenNthCalledWith( 2, 'user-1', - expect.objectContaining({ cursor: 'log-1' }) + expect.objectContaining({ + cursor: 'log-1', + cursorCreatedAt: new Date('2026-07-01T00:00:00.000Z'), + }) ) expect(csv.split('\n')).toHaveLength(3) }) 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 index dc45205d589..ea61af54a25 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -43,6 +43,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, { @@ -52,12 +53,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { endDate: dateRange.endDate, 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 } if (truncated) { diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index ca5019471f8..5985c0d3ced 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -533,6 +533,12 @@ 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 @@ -591,6 +597,7 @@ export async function getUserUsageLogs( endDate, limit = 50, cursor, + cursorCreatedAt, includeSummary = true, } = options @@ -614,20 +621,24 @@ export async function getUserUsageLogs( } 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) { - const cursorCreatedAt = cursorLog[0].createdAt + if (resolvedCursorCreatedAt) { const cursorCondition = or( - lt(usageLog.createdAt, cursorCreatedAt), - and(eq(usageLog.createdAt, cursorCreatedAt), lt(usageLog.id, cursor)) + lt(usageLog.createdAt, resolvedCursorCreatedAt), + and(eq(usageLog.createdAt, resolvedCursorCreatedAt), lt(usageLog.id, cursor)) ) if (cursorCondition) conditions.push(cursorCondition) } From 02c5f393f18f88b1ffdfe1c1fbc0747842ad9afe Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 17:12:54 -0700 Subject: [PATCH 11/17] fix(billing): apportion credits over the whole filtered set, not per page/call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot caught this: the list route apportioned each page's rows against only that page's own dollar total, while the export apportioned every exported row against the complete set's total. Since apportionment depends on the full set, the same log could show a different creditCost between the list and the export, or even between two pages of the same "Load more" list — and the sum of every loaded row could visibly drift from the "Total" header shown above them once more than one page had loaded. Extracted getUsageCreditsByLogId — a single, shared, whole-filter apportionment lookup both routes now call instead of each computing their own subset locally. The list route calls it once per page request (same cost profile as the summary aggregate it already pays for every page); the export calls it once before its pagination loop, not per page, keeping the round-trip count this session's earlier fix already reduced. Also extracted the condition-building shared by the main query, the summary aggregate, and this new lookup into one buildUsageLogConditions helper, removing a third copy of that logic. Verified live: summed every row across 4 "Load more" pages and confirmed it now matches the reported total exactly (previously could drift), and confirmed the list and the export produce byte-identical credit sequences for the same rows. --- .../users/me/usage-logs/export/route.test.ts | 35 +++++++++- .../api/users/me/usage-logs/export/route.ts | 22 +++--- .../app/api/users/me/usage-logs/route.test.ts | 16 ++++- apps/sim/app/api/users/me/usage-logs/route.ts | 21 +++--- apps/sim/lib/billing/core/usage-log.ts | 68 +++++++++++++------ 5 files changed, 121 insertions(+), 41 deletions(-) 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 index f1bc5acb1d5..ec82eec301f 100644 --- 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 @@ -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/export/route' @@ -18,6 +21,7 @@ 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 () => { @@ -43,6 +47,7 @@ describe('GET /api/users/me/usage-logs/export', () => { 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() @@ -181,6 +186,34 @@ describe('GET /api/users/me/usage-logs/export', () => { 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) => ({ 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 index ea61af54a25..d32e6d59837 100644 --- a/apps/sim/app/api/users/me/usage-logs/export/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/export/route.ts @@ -3,8 +3,11 @@ 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 { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' -import { apportionCredits } from '@/lib/billing/credits/conversion' +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' @@ -40,6 +43,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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 @@ -47,10 +56,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let truncated = false while (rows.length < EXPORT_SAFETY_CAP) { const page = await getUserUsageLogs(auth.userId, { - source: source as UsageLogSource | undefined, - workspaceId, - startDate: dateRange.startDate, - endDate: dateRange.endDate, + ...filter, limit: Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length), cursor, cursorCreatedAt, @@ -64,6 +70,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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, @@ -72,8 +80,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - const creditsByLogId = apportionCredits(rows.map((log) => ({ key: log.id, dollars: log.cost }))) - const csvLines = rows.map((log) => { const type = log.source === 'workflow' && log.workflowName 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 28bd84f699d..fe546e263b5 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 () => { @@ -79,6 +83,9 @@ describe('GET /api/users/me/usage-logs', () => { 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() @@ -105,6 +112,13 @@ describe('GET /api/users/me/usage-logs', () => { 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() 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 83f584552d8..0388184c64a 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -3,8 +3,12 @@ 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 { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion' +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' @@ -26,18 +30,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const dateRange = resolveDateRange(period, startDate, endDate) - const result = await getUserUsageLogs(auth.userId, { + const filter = { source: source as UsageLogSource | undefined, workspaceId, startDate: dateRange.startDate, endDate: dateRange.endDate, - limit, - cursor, - }) + } - const creditsByLogId = apportionCredits( - result.logs.map((log) => ({ key: log.id, dollars: log.cost })) - ) + const [result, creditsByLogId] = await Promise.all([ + getUserUsageLogs(auth.userId, { ...filter, limit, cursor }), + getUsageCreditsByLogId(auth.userId, filter), + ]) const logs = result.logs.map((log) => ({ id: log.id, diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 5985c0d3ced..1501dd7edaf 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -7,6 +7,7 @@ import { generateId } from '@sim/utils/id' 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,44 @@ 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))) + + return apportionCredits( + rows.map((row) => ({ key: row.id, dollars: Number.parseFloat(row.cost) })) + ) +} + /** * Options for querying usage logs */ @@ -602,23 +641,7 @@ export async function getUserUsageLogs( } = 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) { let resolvedCursorCreatedAt = cursorCreatedAt @@ -685,11 +708,12 @@ export async function getUserUsageLogs( let totalCost = 0 if (includeSummary) { - 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 summaryConditions = buildUsageLogConditions(userId, { + source, + workspaceId, + startDate, + endDate, + }) const summaryResult = await dbReplica .select({ From b91d2b7e49b25e20f14825cc6e7286734b502a3a Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 17:33:38 -0700 Subject: [PATCH 12/17] fix(billing): make custom-range startDate/endDate nullable, not '' defaulted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startDate/endDate had no sensible static default (they're only ever meaningful mid-custom-range), so defaulting them to '' via .withDefault('') meant switching back to a preset left the URL carrying startDate=&endDate= instead of dropping the params entirely. Made them nullable (no .withDefault) instead, matching the identical fields in the main Logs page's own search-params.ts. Verified live — switching from a custom range back to a preset now clears both params from the URL completely. --- .../settings/billing/credit-usage/credit-usage-view.tsx | 4 ++-- .../settings/billing/credit-usage/search-params.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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 index 0922ffdcd95..ba15e52f1f3 100644 --- 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 @@ -194,8 +194,8 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { 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 index 87d58b92578..9c16d786bed 100644 --- 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 @@ -7,12 +7,14 @@ import { usageLogPeriodSchema } from '@/lib/api/contracts/user' * - `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'`. + * 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.withDefault(''), - endDate: parseAsString.withDefault(''), + startDate: parseAsString, + endDate: parseAsString, } as const /** Filter view-state: clean URLs, no back-stack churn. */ From 55f2bd5c3703e3d45e85786f2c5fb11554b5de50 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 17:46:50 -0700 Subject: [PATCH 13/17] feat(audit-logs): add CSV export, matching the Credit usage page pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Export chip to the top-right of the Audit Logs page (via SettingsPanel's actions slot — the same header mechanism the Credit usage page uses), downloading every audit log matching the current search/type/date filters as CSV. - New GET /api/audit-logs/export route: same session + enterprise admin/owner gating as the existing list route, reuses the shared buildFilterConditions/buildOrgScopeCondition/queryAuditLogs helpers (already using drizzle's typed operators for cursor pagination, not the raw-sql-with-embedded-Date pattern fixed elsewhere this session), and the same fetch+blob+X-Export-Truncated pattern the Credit usage export already established. - Capped at 10,000 rows (not the 50,000 used for a personal credit ledger) — an org's audit trail can genuinely grow much larger than one user's usage history, so this is sized for "a reasonable audit review window," with truncation surfaced via a toast rather than silently dropped. - Bumped the API-validation-contract audit's route-count baseline for the new route. Verified live against a real enterprise org: switched to "All time," exported ~750 real audit log rows, confirmed formatting (quoted descriptions, actor email fallback) and correct filter scoping. --- .../app/api/audit-logs/export/route.test.ts | 169 ++++++++++++++++++ apps/sim/app/api/audit-logs/export/route.ts | 151 ++++++++++++++++ .../ee/audit-logs/components/audit-logs.tsx | 47 ++++- apps/sim/lib/api/contracts/audit-logs.ts | 18 ++ scripts/check-api-validation-contracts.ts | 4 +- 5 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/audit-logs/export/route.test.ts create mode 100644 apps/sim/app/api/audit-logs/export/route.ts 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/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx index e4644da0e77..6493e660652 100644 --- a/apps/sim/ee/audit-logs/components/audit-logs.tsx +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -10,11 +10,13 @@ import { 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' @@ -266,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() @@ -360,8 +363,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, + }, + ]} + >
+ +/** + * 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/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 From bd67ebd226d7923c78d94d794503c58bff4ed288 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 17:57:18 -0700 Subject: [PATCH 14/17] fix(billing): skip wasted credit apportionment on the summary fetch, block export during stale data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot caught two real issues: 1. The compact Billing summary glance (limit=1) only ever reads summary.totalCredits, but the list route unconditionally ran getUsageCreditsByLogId's whole-filter scan on every call including this one — pure wasted work for a caller that discards the result. Added an includeCredits query flag (default true, using the shared booleanQueryFlagSchema) so useUsageSummary can opt out; the main paginated view keeps it on since it genuinely needs per-row values. 2. Export stayed enabled while useUsageLogs held stale rows via keepPreviousData mid-filter-transition — a user could change the period/range and click Export before the new data loaded, exporting against the new filter while the table still showed the old one. Export is now also disabled while isPlaceholderData is true. --- .../app/api/users/me/usage-logs/route.test.ts | 27 +++++++++++++++++++ apps/sim/app/api/users/me/usage-logs/route.ts | 9 ++++--- .../credit-usage/credit-usage-view.tsx | 6 ++++- apps/sim/hooks/queries/usage-logs.ts | 7 ++--- apps/sim/lib/api/contracts/user.ts | 7 +++++ 5 files changed, 49 insertions(+), 7 deletions(-) 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 fe546e263b5..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 @@ -139,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 0388184c64a..d976d188bc8 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -26,7 +26,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(getUsageLogsContract, request, {}) if (!parsed.success) return parsed.response - const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query + const { source, workspaceId, period, startDate, endDate, limit, cursor, includeCredits } = + parsed.data.query const dateRange = resolveDateRange(period, startDate, endDate) @@ -39,7 +40,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const [result, creditsByLogId] = await Promise.all([ getUserUsageLogs(auth.userId, { ...filter, limit, cursor }), - getUsageCreditsByLogId(auth.userId, filter), + includeCredits + ? getUsageCreditsByLogId(auth.userId, filter) + : Promise.resolve>({}), ]) const logs = result.logs.map((log) => ({ @@ -47,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { createdAt: log.createdAt, source: log.source, workflowName: log.workflowName ?? null, - creditCost: creditsByLogId[log.id], + creditCost: creditsByLogId[log.id] ?? 0, dollarCost: log.cost, })) 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 index ba15e52f1f3..5e29dfc68f1 100644 --- 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 @@ -158,7 +158,11 @@ export function CreditUsageView({ workspaceId }: CreditUsageViewProps) { } actions={ - void handleExport()} disabled={logs.length === 0}> + void handleExport()} + disabled={logs.length === 0 || isPlaceholderData} + > Export } diff --git a/apps/sim/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts index 58626313d90..f67bb058f51 100644 --- a/apps/sim/hooks/queries/usage-logs.ts +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -23,10 +23,11 @@ async function fetchUsageLogs( filter: UsagePeriodFilter, limit: number, cursor: string | undefined, - signal?: AbortSignal + signal?: AbortSignal, + includeCredits = true ): Promise { return requestJson(getUsageLogsContract, { - query: { ...filter, limit, cursor }, + query: { ...filter, limit, cursor, includeCredits }, signal, }) } @@ -63,7 +64,7 @@ export function useUsageLogs({ period, startDate, endDate, enabled = true }: Use export function useUsageSummary(period: Exclude) { return useQuery({ queryKey: usageLogKeys.summary(period), - queryFn: ({ signal }) => fetchUsageLogs({ period }, 1, undefined, signal), + queryFn: ({ signal }) => fetchUsageLogs({ period }, 1, undefined, signal, false), staleTime: 30 * 1000, select: (data) => data.summary.totalCredits, }) diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts index bdce7b23883..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' @@ -309,6 +310,12 @@ 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, From 447c404711a4d537fd0348ff7db554041e8568a5 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 18:09:51 -0700 Subject: [PATCH 15/17] fix(billing): deterministic apportionment order, block audit export during stale data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot caught two more real issues on the latest push: 1. Same stale-export bug as the earlier Credit usage fix, this time in Audit Logs: Export stayed enabled while useAuditLogs held prior rows via keepPreviousData, so it could export against a just-changed filter while the table still showed the old one. Now also disabled while isPlaceholderData is true. 2. getUsageCreditsByLogId had no ORDER BY before apportionCredits's largest-remainder tie-break, so which row absorbed a tied remainder credit depended on undefined Postgres row order — the same event's displayed credit could flip between calls (list vs. export, or even two successive requests). Added the same `orderBy(desc(createdAt), desc(id))` the main list query already uses, making the tie-break reproducible. Verified live: 3 identically-costed rows produced the same tie-break winner across 3 repeated requests (previously order-dependent). --- apps/sim/ee/audit-logs/components/audit-logs.tsx | 13 ++++++++++--- apps/sim/lib/billing/core/usage-log.ts | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx index 6493e660652..0819fa25782 100644 --- a/apps/sim/ee/audit-logs/components/audit-logs.tsx +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -297,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 [] @@ -403,7 +410,7 @@ export function AuditLogs() { text: 'Export', icon: Download, onSelect: () => void handleExportCsv(), - disabled: allEntries.length === 0 || isExporting, + disabled: allEntries.length === 0 || isExporting || isPlaceholderData, }, ]} > diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 1501dd7edaf..0638d31472d 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -550,6 +550,7 @@ export async function getUsageCreditsByLogId( .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) })) From ecf550546c4fc18a655debc78220ce9fd28f0033 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 18:22:04 -0700 Subject: [PATCH 16/17] fix(billing): distinguish a failed summary fetch from zero usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact Billing glance only branched on isPending, so once useUsageSummary settled into an error state, totalCredits stayed undefined and formatCreditsLabel(0) rendered "0 credits" — visually identical to genuinely having no usage this period. Now shows the same neutral "—" placeholder for isError as it already does for isPending. --- .../components/credit-usage-section/credit-usage-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 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 7f8154e7c7e..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 @@ -18,14 +18,14 @@ interface CreditUsageSectionProps { * except Enterprise, which manages billing out-of-band. */ export function CreditUsageSection({ workspaceId }: CreditUsageSectionProps) { - const { data: totalCredits, isPending } = useUsageSummary(SUMMARY_PERIOD) + const { data: totalCredits, isPending, isError } = useUsageSummary(SUMMARY_PERIOD) return (
- {isPending ? '—' : formatCreditsLabel(totalCredits ?? 0)} + {isPending || isError ? '—' : formatCreditsLabel(totalCredits ?? 0)} Last 30 days
From 17a5a733cd48958085610f015f7e89b89983a2e0 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 3 Jul 2026 18:59:15 -0700 Subject: [PATCH 17/17] fix(billing): gate the credit-usage page server-side for enterprise accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile (P1) caught this: hiding the "View usage logs" link on the Billing page for enterprise accounts doesn't stop direct navigation — anyone with the URL (bookmark, shared link, browser history) could still reach the full page and its CSV export, which enterprise accounts were never supposed to see at all (billing is managed out-of-band for them). Added a server-side check in page.tsx before anything renders: resolve the session, look up the highest-priority subscription, and redirect to /settings/billing if it's enterprise — matching how getHighestPrioritySubscription is already used elsewhere for server-side plan checks, rather than relying on a client-side-only conditional the way the Billing page's inline section does. Also fixes loading.tsx: it was a Server Component (no directive) passing a raw icon function reference into the client Chip component, which fails RSC serialization. Added 'use client'. Verified live in a real browser against both an enterprise account (redirects to Billing before any credit-usage content renders) and a non-enterprise account (reaches the page normally). --- .../settings/billing/credit-usage/loading.tsx | 2 ++ .../settings/billing/credit-usage/page.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) 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 index 13172328e8b..6f8fe66f7b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/loading.tsx @@ -1,3 +1,5 @@ +'use client' + import { Chip } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { CredentialDetailLayout } from '@/app/workspace/[workspaceId]/components/credential-detail' 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 index 54f4f00d304..367f1cdbb7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx @@ -1,5 +1,9 @@ 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' @@ -12,6 +16,12 @@ export const metadata: Metadata = { * `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, @@ -19,6 +29,15 @@ export default async function CreditUsagePage({ 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 ( }>