mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(billing): expose credit usage log in Billing settings (#5391)
* feat(billing): expose credit usage log in Billing settings
Add a "Credit usage" section under Billing, below Invoices, showing a
paginated, period-filterable list of individual credit-consuming
events (model, tool, and fixed charges) for every plan except
Enterprise. Wires the existing (previously unused) usage-logs backend
to a proper contract, React Query hook, and emcn-styled UI:
- getUsageLogsContract in contracts/user.ts, broadened the source enum
to match the real usage_log schema
- Rewrote the route to use parseRequest per the API boundary rules
- useUsageLogs infinite-query hook, keyset-paginated
- CreditUsageSection: period dropdown, total-credits summary, row
list with source badges, "Load more"
- Extracted formatCreditsLabel in conversion.ts as the shared
formatter for already-converted integer credits
* fix(billing): move usage-log key factory out of the 'use client' boundary
Greptile P2: usageLogKeys lived in the 'use client' usage-logs.ts hook
file — importing it from a server component (e.g. a future prefetch)
would resolve to a client-reference stub and crash at build/SSR, the
same class of bug that hit tables' key factory before. Extracted to
hooks/queries/utils/usage-log-keys.ts, matching table-keys.ts and
folder-keys.ts.
Also renamed a shadowed `source` map param in the route to `sourceKey`
for clarity.
* chore(billing): dedupe the usage-log period literal type
The '1d' | '7d' | '30d' | 'all' union was hand-typed in three places
across usage-logs.ts and credit-usage-section.tsx. Extracted
usageLogPeriodSchema in the contract and derived UsageLogPeriod from
it, so the hook, the component, and the key factory all share one
definition instead of risking drift.
* improvement(billing): move credit usage period filter into the URL
/cleanup pass (nuqs rule, react-query-best-practices, emcn review):
- period was a plain useState, but it's exactly the kind of shareable
list filter sim-url-state.md calls out for nuqs — migrated to
billingParsers/useQueryStates so the selection deep-links, survives
reload, and matches every sibling settings section (recently-deleted,
inbox, teammates). Derives its literal values from
usageLogPeriodSchema instead of a fourth copy of the same union.
- Removed an unjustified showSelectedCheck={false} on the period
ChipDropdown — the one other usage in the codebase is a one-shot
action menu with no persistent selection; this is a real filter and
should show the check like the default intends.
- Added placeholderData: keepPreviousData to useUsageLogs — period is
a variable query key, so without it switching periods flashed the
loading empty-state instead of smoothly transitioning.
Verified live: deep-link with ?period=7d pre-selects and loads
correctly, switching periods updates the URL, and the selection
survives a hard reload.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetUserUsageLogs } = vi.hoisted(() => ({
|
||||
mockGetUserUsageLogs: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/usage-log', () => ({
|
||||
getUserUsageLogs: mockGetUserUsageLogs,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/users/me/usage-logs/route'
|
||||
|
||||
describe('GET /api/users/me/usage-logs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
mockGetUserUsageLogs.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
category: 'model',
|
||||
source: 'workflow',
|
||||
description: 'gpt-4o',
|
||||
cost: 0.5,
|
||||
},
|
||||
],
|
||||
summary: { totalCost: 0.5, bySource: { workflow: 0.5 } },
|
||||
pagination: { hasMore: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(createMockRequest('GET'))
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('converts dollar costs to credits in the logs and summary', async () => {
|
||||
const response = await GET(createMockRequest('GET'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(body.logs).toEqual([
|
||||
{
|
||||
id: 'log-1',
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
source: 'workflow',
|
||||
description: 'gpt-4o',
|
||||
creditCost: 100,
|
||||
},
|
||||
])
|
||||
expect(body.summary).toEqual({
|
||||
totalCredits: 100,
|
||||
bySourceCredits: { workflow: 100 },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an invalid period', async () => {
|
||||
const response = await GET(
|
||||
createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=1y')
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockGetUserUsageLogs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the start date from the period filter', async () => {
|
||||
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=7d'))
|
||||
|
||||
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ startDate: expect.any(Date) })
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the start date for the "all" period', async () => {
|
||||
await GET(createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/test?period=all'))
|
||||
|
||||
expect(mockGetUserUsageLogs).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ startDate: undefined })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { usageLogsQuerySchema } from '@/lib/api/contracts/user'
|
||||
import { getUsageLogsContract } from '@/lib/api/contracts/user'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log'
|
||||
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
@@ -9,108 +9,68 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('UsageLogsAPI')
|
||||
|
||||
const PERIOD_TO_DAYS: Record<'1d' | '7d' | '30d', number> = { '1d': 1, '7d': 7, '30d': 30 }
|
||||
|
||||
function resolveStartDate(period: '1d' | '7d' | '30d' | 'all'): Date | undefined {
|
||||
if (period === 'all') return undefined
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - PERIOD_TO_DAYS[period])
|
||||
return startDate
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/users/me/usage-logs
|
||||
* Get usage logs for the authenticated user
|
||||
* Lists the authenticated user's credit-consuming usage events (model, tool,
|
||||
* and fixed charges), converted to credits for display in Billing settings.
|
||||
*/
|
||||
export const GET = withRouteHandler(async (req: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const queryParams = {
|
||||
source: searchParams.get('source') || undefined,
|
||||
workspaceId: searchParams.get('workspaceId') || undefined,
|
||||
period: searchParams.get('period') || '30d',
|
||||
limit: searchParams.get('limit') || '50',
|
||||
cursor: searchParams.get('cursor') || undefined,
|
||||
}
|
||||
|
||||
const validation = usageLogsQuerySchema.safeParse(queryParams)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid query parameters',
|
||||
details: validation.error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { source, workspaceId, period, limit, cursor } = validation.data
|
||||
|
||||
let startDate: Date | undefined
|
||||
const endDate = new Date()
|
||||
|
||||
if (period !== 'all') {
|
||||
startDate = new Date()
|
||||
switch (period) {
|
||||
case '1d':
|
||||
startDate.setDate(startDate.getDate() - 1)
|
||||
break
|
||||
case '7d':
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
break
|
||||
case '30d':
|
||||
startDate.setDate(startDate.getDate() - 30)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getUserUsageLogs(userId, {
|
||||
source: source as UsageLogSource | undefined,
|
||||
workspaceId,
|
||||
startDate,
|
||||
endDate,
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
|
||||
const logsWithCredits = result.logs.map((log) => ({
|
||||
...log,
|
||||
creditCost: dollarsToCredits(log.cost),
|
||||
}))
|
||||
|
||||
const bySourceCredits: Record<string, number> = {}
|
||||
for (const [src, cost] of Object.entries(result.summary.bySource)) {
|
||||
bySourceCredits[src] = dollarsToCredits(cost)
|
||||
}
|
||||
|
||||
logger.debug('Retrieved usage logs', {
|
||||
userId,
|
||||
source,
|
||||
period,
|
||||
logCount: result.logs.length,
|
||||
hasMore: result.pagination.hasMore,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
logs: logsWithCredits,
|
||||
summary: {
|
||||
...result.summary,
|
||||
totalCostCredits: dollarsToCredits(result.summary.totalCost),
|
||||
bySourceCredits,
|
||||
},
|
||||
pagination: result.pagination,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage logs', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to retrieve usage logs',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getUsageLogsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { source, workspaceId, period, limit, cursor } = parsed.data.query
|
||||
|
||||
const result = await getUserUsageLogs(auth.userId, {
|
||||
source: source as UsageLogSource | undefined,
|
||||
workspaceId,
|
||||
startDate: resolveStartDate(period),
|
||||
endDate: new Date(),
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
|
||||
const logs = result.logs.map((log) => ({
|
||||
id: log.id,
|
||||
createdAt: log.createdAt,
|
||||
source: log.source,
|
||||
description: log.description,
|
||||
creditCost: dollarsToCredits(log.cost),
|
||||
}))
|
||||
|
||||
const bySourceCredits = Object.fromEntries(
|
||||
Object.entries(result.summary.bySource).map(([sourceKey, cost]) => [
|
||||
sourceKey,
|
||||
dollarsToCredits(cost),
|
||||
])
|
||||
)
|
||||
|
||||
logger.debug('Retrieved usage logs', {
|
||||
userId: auth.userId,
|
||||
source,
|
||||
period,
|
||||
logCount: logs.length,
|
||||
hasMore: result.pagination.hasMore,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
logs,
|
||||
summary: {
|
||||
totalCredits: dollarsToCredits(result.summary.totalCost),
|
||||
bySourceCredits,
|
||||
},
|
||||
pagination: result.pagination,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section'
|
||||
import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field'
|
||||
import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
@@ -640,6 +641,8 @@ export function Billing() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{!subscription.isEnterprise && <CreditUsageSection />}
|
||||
</SettingsPanel>
|
||||
)
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { Badge, ChipDropdown, type ChipDropdownOption, chipVariants, cn } from '@sim/emcn'
|
||||
import { formatDateTime } from '@sim/utils/formatting'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { UsageLogEntry, UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user'
|
||||
import { formatCreditsLabel } from '@/lib/billing/credits/conversion'
|
||||
import {
|
||||
billingParsers,
|
||||
billingUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/billing/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useUsageLogs } from '@/hooks/queries/usage-logs'
|
||||
|
||||
const PERIOD_OPTIONS: ReadonlyArray<ChipDropdownOption> = [
|
||||
{ value: '1d', label: 'Today' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Humanized labels for `usage_log.source`. Avoids the internal "copilot" /
|
||||
* "mothership" naming — the agent is always "Sim", the surface is "Chat".
|
||||
*/
|
||||
const SOURCE_LABELS: Record<UsageLogSource, string> = {
|
||||
workflow: 'Workflow',
|
||||
wand: 'Wand',
|
||||
copilot: 'Chat',
|
||||
'workspace-chat': 'Chat',
|
||||
mcp_copilot: 'Chat (MCP)',
|
||||
mothership_block: 'Agent block',
|
||||
'knowledge-base': 'Knowledge Base',
|
||||
'voice-input': 'Voice input',
|
||||
enrichment: 'Enrichment',
|
||||
}
|
||||
|
||||
interface UsageLogRowProps {
|
||||
log: UsageLogEntry
|
||||
}
|
||||
|
||||
function UsageLogRow({ log }: UsageLogRowProps) {
|
||||
return (
|
||||
<div className='flex items-center gap-2.5 rounded-lg p-2 text-left'>
|
||||
<span className='w-[150px] flex-shrink-0 text-[12px] text-[var(--text-muted)]'>
|
||||
{formatDateTime(new Date(log.createdAt))}
|
||||
</span>
|
||||
<span className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-body)]'>
|
||||
{log.description}
|
||||
</span>
|
||||
<Badge variant='gray-secondary' size='sm' className='flex-shrink-0'>
|
||||
{SOURCE_LABELS[log.source]}
|
||||
</Badge>
|
||||
<span className='flex-shrink-0 text-[12px] text-[var(--text-muted)] tabular-nums'>
|
||||
{formatCreditsLabel(log.creditCost)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the credit-consuming usage events behind a user's billing period —
|
||||
* the same underlying ledger the usage limit and cost breakdown are computed
|
||||
* from — as a paginated, filterable list. Shown to every non-enterprise plan
|
||||
* so builders can see exactly where their credits went.
|
||||
*/
|
||||
export function CreditUsageSection() {
|
||||
const [{ period }, setFilters] = useQueryStates(billingParsers, billingUrlKeys)
|
||||
|
||||
const { data, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } = useUsageLogs(
|
||||
{ period }
|
||||
)
|
||||
|
||||
const logs = data?.pages.flatMap((page) => page.logs) ?? []
|
||||
const totalCredits = data?.pages[0]?.summary.totalCredits ?? 0
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
label='Credit usage'
|
||||
action={
|
||||
<ChipDropdown
|
||||
options={PERIOD_OPTIONS}
|
||||
value={period}
|
||||
onChange={(value) => setFilters({ period: value as UsageLogPeriod })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
{isLoading ? (
|
||||
<SettingsEmptyState variant='inline'>Loading usage…</SettingsEmptyState>
|
||||
) : isError ? (
|
||||
<SettingsEmptyState variant='inline'>Couldn't load credit usage.</SettingsEmptyState>
|
||||
) : logs.length === 0 ? (
|
||||
<SettingsEmptyState variant='inline'>No credit usage in this period.</SettingsEmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex items-center justify-between px-2 pb-2 text-small'>
|
||||
<span className='text-[var(--text-muted)]'>Total</span>
|
||||
<span className='text-[var(--text-body)] tabular-nums'>
|
||||
{formatCreditsLabel(totalCredits)}
|
||||
</span>
|
||||
</div>
|
||||
{logs.map((log) => (
|
||||
<UsageLogRow key={log.id} log={log} />
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
aria-label='Load more usage'
|
||||
className={cn(
|
||||
chipVariants({ fullWidth: true }),
|
||||
'text-[var(--text-muted)] text-small'
|
||||
)}
|
||||
>
|
||||
{isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { parseAsStringLiteral } from 'nuqs/server'
|
||||
import { usageLogPeriodSchema } from '@/lib/api/contracts/user'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query-param definitions for the Billing settings
|
||||
* view.
|
||||
*
|
||||
* - `period` is the Credit usage section's time-window filter, sharing its
|
||||
* literal values with {@link usageLogPeriodSchema} so the URL parser can
|
||||
* never drift from the API contract it filters.
|
||||
*/
|
||||
export const billingParsers = {
|
||||
period: parseAsStringLiteral(usageLogPeriodSchema.options).withDefault('30d'),
|
||||
} as const
|
||||
|
||||
/** Filter view-state: clean URLs, no back-stack churn. */
|
||||
export const billingUrlKeys = {
|
||||
history: 'replace',
|
||||
shallow: true,
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import {
|
||||
getUsageLogsContract,
|
||||
type UsageLogPeriod,
|
||||
type UsageLogsApiResponse,
|
||||
} from '@/lib/api/contracts/user'
|
||||
import { usageLogKeys } from '@/hooks/queries/utils/usage-log-keys'
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
async function fetchUsageLogs(
|
||||
period: UsageLogPeriod,
|
||||
cursor: string | undefined,
|
||||
signal?: AbortSignal
|
||||
): Promise<UsageLogsApiResponse> {
|
||||
return requestJson(getUsageLogsContract, {
|
||||
query: { period, limit: PAGE_SIZE, cursor },
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
interface UseUsageLogsOptions {
|
||||
period: UsageLogPeriod
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite-scrolls the authenticated user's credit-consuming usage events for
|
||||
* the Billing settings "Credit usage" section, keyset-paginated by the
|
||||
* backend's opaque `nextCursor`. Keeps the prior period's rows on screen
|
||||
* while a newly selected period loads, since `period` is a variable key.
|
||||
*/
|
||||
export function useUsageLogs({ period, enabled = true }: UseUsageLogsOptions) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: usageLogKeys.list(period),
|
||||
queryFn: ({ pageParam, signal }) => fetchUsageLogs(period, pageParam, signal),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.pagination.hasMore ? lastPage.pagination.nextCursor : undefined,
|
||||
enabled,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* React Query key factory for the credit usage log.
|
||||
*
|
||||
* Lives in this standalone (non-`'use client'`) module — like
|
||||
* {@link file://./table-keys.ts} — so it can be imported from server
|
||||
* components without pulling in the `'use client'`
|
||||
* `@/hooks/queries/usage-logs` module, whose exports would otherwise
|
||||
* resolve to client-reference stubs on the server.
|
||||
*/
|
||||
|
||||
import type { UsageLogPeriod, UsageLogSource } from '@/lib/api/contracts/user'
|
||||
|
||||
export const usageLogKeys = {
|
||||
all: ['usage-logs'] as const,
|
||||
lists: () => [...usageLogKeys.all, 'list'] as const,
|
||||
list: (period: UsageLogPeriod, source?: UsageLogSource) =>
|
||||
[...usageLogKeys.lists(), period, source ?? ''] as const,
|
||||
}
|
||||
@@ -264,14 +264,65 @@ export type UnsubscribeActionResponse = ContractJsonResponse<typeof unsubscribeP
|
||||
export type UnsubscribeBody = z.input<typeof unsubscribeBodySchema>
|
||||
export type UnsubscribeType = NonNullable<UnsubscribeBody['type']>
|
||||
|
||||
export const usageLogSourceSchema = z.enum([
|
||||
'workflow',
|
||||
'wand',
|
||||
'copilot',
|
||||
'workspace-chat',
|
||||
'mcp_copilot',
|
||||
'mothership_block',
|
||||
'knowledge-base',
|
||||
'voice-input',
|
||||
'enrichment',
|
||||
])
|
||||
|
||||
export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all'])
|
||||
|
||||
export const usageLogsQuerySchema = z.object({
|
||||
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
|
||||
source: usageLogSourceSchema.optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
period: z.enum(['1d', '7d', '30d', 'all']).optional().default('30d'),
|
||||
period: usageLogPeriodSchema.optional().default('30d'),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
cursor: z.string().optional(),
|
||||
})
|
||||
|
||||
export const usageLogEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.string(),
|
||||
source: usageLogSourceSchema,
|
||||
description: z.string(),
|
||||
/** Credit-denominated cost of this event (Sim's usage unit; 1,000 credits = $5). */
|
||||
creditCost: z.number(),
|
||||
})
|
||||
|
||||
export const usageLogsApiResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
logs: z.array(usageLogEntrySchema),
|
||||
summary: z.object({
|
||||
totalCredits: z.number(),
|
||||
bySourceCredits: z.record(z.string(), z.number()),
|
||||
}),
|
||||
pagination: z.object({
|
||||
nextCursor: z.string().optional(),
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const getUsageLogsContract = defineRouteContract({
|
||||
method: 'GET',
|
||||
path: '/api/users/me/usage-logs',
|
||||
query: usageLogsQuerySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: usageLogsApiResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
export type UsageLogSource = z.output<typeof usageLogSourceSchema>
|
||||
export type UsageLogPeriod = z.output<typeof usageLogPeriodSchema>
|
||||
export type UsageLogEntry = z.output<typeof usageLogEntrySchema>
|
||||
export type UsageLogsApiResponse = z.output<typeof usageLogsApiResponseSchema>
|
||||
|
||||
export const subscriptionTransferParamsSchema = z.object({
|
||||
id: z.string({ error: 'Subscription ID is required' }).min(1, 'Subscription ID is required'),
|
||||
})
|
||||
|
||||
@@ -21,6 +21,18 @@ export function creditsToDollars(credits: number): number {
|
||||
return credits / CREDIT_MULTIPLIER
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluralizes an already credit-denominated integer, e.g. `1234` -> `"1,234
|
||||
* credits"`, `1` -> `"1 credit"`. Low-level building block for
|
||||
* {@link formatCreditCost} — also useful directly for consumers that already
|
||||
* hold precise integer credits (e.g. a server-converted `creditCost` value),
|
||||
* which would otherwise double-convert by round-tripping back through
|
||||
* dollars.
|
||||
*/
|
||||
export function formatCreditsLabel(credits: number): string {
|
||||
return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for rendering a dollar cost as a credit label.
|
||||
*
|
||||
@@ -50,7 +62,7 @@ export function formatCreditCost(
|
||||
return opts?.emptyForZeroOrLess ? undefined : '0 credits'
|
||||
}
|
||||
|
||||
return `${credits.toLocaleString()} ${credits === 1 ? 'credit' : 'credits'}`
|
||||
return formatCreditsLabel(credits)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user