Skip to content

Commit 10089ba

Browse files
committed
improvements
1 parent 44f4a04 commit 10089ba

10 files changed

Lines changed: 169 additions & 26 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { getMyMemberCreditsContract } from '@/lib/api/contracts/organization'
3+
import { parseRequest } from '@/lib/api/server'
4+
import { getSession } from '@/lib/auth'
5+
import { checkOrgMemberUsageLimit } from '@/lib/billing/calculations/usage-monitor'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
8+
/**
9+
* GET /api/billing/member-credits?workspaceId=...
10+
*
11+
* Returns the caller's OWN per-member usage and cap inside the workspace's
12+
* organization, in DOLLARS (the DB unit) so the client's `formatCredits` does the
13+
* single dollars→credits conversion. Own-data only, so no admin gate (unlike the
14+
* org/member admin route). Reuses {@link checkOrgMemberUsageLimit}, which yields a
15+
* null limit — and the chip falls back to the plan-level view — whenever no
16+
* per-member cap applies: non-hosted, the workspace isn't org-owned, or no cap is
17+
* set for this member.
18+
*/
19+
export const GET = withRouteHandler(async (request: NextRequest) => {
20+
const session = await getSession()
21+
if (!session?.user?.id) {
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const parsed = await parseRequest(getMyMemberCreditsContract, request, {})
26+
if (!parsed.success) return parsed.response
27+
28+
const { workspaceId } = parsed.data.query
29+
const { currentUsage, limit } = await checkOrgMemberUsageLimit(session.user.id, workspaceId)
30+
31+
return NextResponse.json({
32+
success: true,
33+
data: {
34+
usedDollars: currentUsage,
35+
limitDollars: limit,
36+
},
37+
})
38+
})

apps/sim/app/api/speech/token/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
155155
{
156156
error:
157157
usageCheck.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
158+
scope: usageCheck.scope,
158159
},
159160
{ status: 402 }
160161
)

apps/sim/app/api/wand/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
294294
{
295295
success: false,
296296
error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
297+
scope: usage.scope,
297298
},
298299
{ status: 402 }
299300
)

apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Credit } from '@/components/emcn/icons'
88
import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
99
import { formatCredits } from '@/lib/billing/credits/conversion'
1010
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
11+
import { useMyMemberCredits } from '@/hooks/queries/organization'
1112
import { usePlanView } from '@/hooks/queries/plan-view'
1213
import { prefetchUpgradeBillingData, useSubscriptionData } from '@/hooks/queries/subscription'
1314
import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
@@ -30,6 +31,7 @@ function CreditsChipInner() {
3031
const router = useRouter()
3132
const queryClient = useQueryClient()
3233
const { workspaceId } = useParams<{ workspaceId: string }>()
34+
const { data: memberCredits } = useMyMemberCredits(workspaceId)
3335

3436
const upgradeHref = `/workspace/${workspaceId}/upgrade`
3537

@@ -43,6 +45,29 @@ function CreditsChipInner() {
4345
prefetchWorkspaceSettings(queryClient, workspaceId)
4446
}, [router, queryClient, upgradeHref, workspaceId])
4547

48+
const renderChip = (dollars: number) => (
49+
<Chip
50+
aria-label='Credits remaining — upgrade plan'
51+
onClick={() => router.push(upgradeHref)}
52+
onMouseEnter={prefetchUpgrade}
53+
onFocus={prefetchUpgrade}
54+
leftIcon={Credit}
55+
>
56+
{formatCredits(dollars)}
57+
</Chip>
58+
)
59+
60+
/**
61+
* A per-member org credit cap is the authoritative personal remaining for this
62+
* member — show it even when the plan-based chip would otherwise be hidden (e.g.
63+
* external members). Values are dollars (the chip formats via `formatCredits`),
64+
* clamped at 0 so an over-cap member never sees a negative.
65+
*/
66+
const limitDollars = memberCredits?.limitDollars ?? null
67+
if (limitDollars !== null) {
68+
return renderChip(Math.max(0, limitDollars - (memberCredits?.usedDollars ?? 0)))
69+
}
70+
4671
if (isLoading || !hasData || !data?.data) return null
4772
if (!planView.showCredits) return null
4873

@@ -58,15 +83,5 @@ function CreditsChipInner() {
5883
? ON_DEMAND_UNLIMITED
5984
: Math.max(0, usageLimit + creditBalance - currentUsage)
6085

61-
return (
62-
<Chip
63-
aria-label='Credits remaining — upgrade plan'
64-
onClick={() => router.push(upgradeHref)}
65-
onMouseEnter={prefetchUpgrade}
66-
onFocus={prefetchUpgrade}
67-
leftIcon={Credit}
68-
>
69-
{formatCredits(remainingCredits)}
70-
</Chip>
71-
)
86+
return renderChip(remainingCredits)
7287
}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from 'react'
1414
import { createLogger } from '@sim/logger'
1515
import { useParams } from 'next/navigation'
16-
import { Button, Paperclip, Slash, Tooltip } from '@/components/emcn'
16+
import { Button, Paperclip, Slash, Tooltip, toast } from '@/components/emcn'
1717
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
1818
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
1919
import { cn } from '@/lib/core/utils/cn'
@@ -392,8 +392,20 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
392392
valueRef.current = converted
393393
}
394394

395-
function handleUsageLimitExceeded() {
396-
navigateToSettings({ section: 'billing' })
395+
function handleUsageLimitExceeded(message?: string, isMemberLimit?: boolean) {
396+
// A per-member cap can only be raised by an org admin, so don't offer Upgrade
397+
// (the member can't act on it) — the message already tells them to ask an admin.
398+
toast.error(
399+
message || 'You are out of credits.',
400+
isMemberLimit
401+
? undefined
402+
: {
403+
action: {
404+
label: 'Upgrade',
405+
onClick: () => navigateToSettings({ section: 'billing' }),
406+
},
407+
}
408+
)
397409
}
398410

399411
const {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,20 @@ export function useWand({
245245
if (error.name === 'AbortError') {
246246
logger.debug('Wand generation cancelled')
247247
} else if (isApiClientError(error) && error.status === 402) {
248-
toast.error(error.message || 'Usage limit reached', {
249-
action: {
250-
label: 'Upgrade',
251-
onClick: () => navigateToSettings({ section: 'billing' }),
252-
},
253-
})
248+
// A per-member cap is only raisable by an org admin, so skip the Upgrade
249+
// affordance the member can't act on.
250+
const isMemberLimit = (error.body as { scope?: string } | null)?.scope === 'member'
251+
toast.error(
252+
error.message || 'Usage limit reached',
253+
isMemberLimit
254+
? undefined
255+
: {
256+
action: {
257+
label: 'Upgrade',
258+
onClick: () => navigateToSettings({ section: 'billing' }),
259+
},
260+
}
261+
)
254262
} else {
255263
logger.error('Wand generation failed', { error })
256264
setError(error.message || 'Generation failed')

apps/sim/hooks/queries/organization.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ import {
1616
} from '@/lib/api/contracts/invitations'
1717
import {
1818
createOrganizationContract,
19+
getMyMemberCreditsContract,
1920
getOrganizationMemberUsageLimitContract,
2021
getOrganizationRosterContract,
2122
inviteOrganizationMembersContract,
2223
listOrganizationMembersContract,
24+
type MyMemberCreditsData,
2325
type OrganizationMembersResponse,
2426
type OrganizationMemberUsageLimitData,
2527
type OrganizationRoster,
@@ -107,6 +109,8 @@ export const organizationKeys = {
107109
memberUsageLimit: (id: string, userId: string) =>
108110
[...organizationKeys.detail(id), 'member-usage-limit', userId] as const,
109111
roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const,
112+
myMemberCredits: (workspaceId: string) =>
113+
[...organizationKeys.all, 'my-member-credits', workspaceId] as const,
110114
}
111115

112116
export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess }
@@ -540,6 +544,31 @@ export function useUpdateOrganizationMemberUsageLimit() {
540544
})
541545
}
542546

547+
async function fetchMyMemberCredits(
548+
workspaceId: string,
549+
signal?: AbortSignal
550+
): Promise<MyMemberCreditsData> {
551+
const response = await requestJson(getMyMemberCreditsContract, {
552+
query: { workspaceId },
553+
signal,
554+
})
555+
return response.data
556+
}
557+
558+
/**
559+
* The caller's OWN per-member credit usage + cap for a workspace's organization.
560+
* `creditLimit` is null when no per-member cap applies (non-hosted, non-org
561+
* workspace, or no cap set) — callers then fall back to the plan-level view.
562+
*/
563+
export function useMyMemberCredits(workspaceId?: string) {
564+
return useQuery({
565+
queryKey: organizationKeys.myMemberCredits(workspaceId ?? ''),
566+
queryFn: ({ signal }) => fetchMyMemberCredits(workspaceId as string, signal),
567+
enabled: Boolean(workspaceId),
568+
staleTime: 30 * 1000,
569+
})
570+
}
571+
543572
type TransferOwnershipParams = {
544573
orgId: string
545574
} & ContractBodyInput<typeof transferOwnershipContract>

apps/sim/hooks/use-speech-to-text.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ export type PermissionState = 'prompt' | 'granted' | 'denied'
2020

2121
interface UseSpeechToTextProps {
2222
onTranscript: (text: string) => void
23-
onUsageLimitExceeded?: () => void
23+
/**
24+
* Called on a 402 from the token endpoint, with the server's limit message and
25+
* whether it was a per-member cap (which only an org admin can raise).
26+
*/
27+
onUsageLimitExceeded?: (message?: string, isMemberLimit?: boolean) => void
2428
language?: string
2529
/** Attributes the voice-input cost to this workspace for per-member usage. */
2630
workspaceId?: string
@@ -176,7 +180,8 @@ export function useSpeechToText({
176180
})
177181
} catch (err) {
178182
if (isApiClientError(err) && err.status === 402) {
179-
onUsageLimitExceededRef.current?.()
183+
const isMemberLimit = (err.body as { scope?: string } | null)?.scope === 'member'
184+
onUsageLimitExceededRef.current?.(err.message, isMemberLimit)
180185
return false
181186
}
182187
throw err instanceof Error ? err : new Error('Failed to get speech token')

apps/sim/lib/api/contracts/organization.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod'
2+
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
23
import { organizationBillingDataSchema } from '@/lib/api/contracts/subscription'
34
import { defineRouteContract } from '@/lib/api/contracts/types'
45
import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces'
@@ -408,6 +409,37 @@ export const updateOrganizationMemberUsageLimitContract = defineRouteContract({
408409
},
409410
})
410411

412+
/**
413+
* Self-service per-member usage for the chat-home credits chip. Values are in
414+
* DOLLARS (the DB unit) so the client's `formatCredits` performs the single
415+
* dollars→credits conversion — returning credits here would double-convert.
416+
* `limitDollars` is null when no per-member cap applies (non-hosted, the
417+
* workspace isn't org-owned, or no cap is set), so the chip falls back to the
418+
* plan-level credits view.
419+
*/
420+
export const myMemberCreditsDataSchema = z.object({
421+
usedDollars: z.number(),
422+
limitDollars: z.number().nullable(),
423+
})
424+
export type MyMemberCreditsData = z.infer<typeof myMemberCreditsDataSchema>
425+
426+
/**
427+
* Own-data-only (no admin gate, unlike the admin route above) and workspace-
428+
* scoped, so the chat-home chip can resolve the acting member's own remaining.
429+
*/
430+
export const getMyMemberCreditsContract = defineRouteContract({
431+
method: 'GET',
432+
path: '/api/billing/member-credits',
433+
query: z.object({ workspaceId: workspaceIdSchema }),
434+
response: {
435+
mode: 'json',
436+
schema: z.object({
437+
success: z.boolean(),
438+
data: myMemberCreditsDataSchema,
439+
}),
440+
},
441+
})
442+
411443
export const transferOwnershipContract = defineRouteContract({
412444
method: 'POST',
413445
path: '/api/organizations/[id]/transfer-ownership',

apps/sim/lib/billing/calculations/usage-monitor.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -499,21 +499,23 @@ export async function checkOrgMemberUsageLimit(
499499
* ({@link checkOrgMemberUsageLimit}) when a workspace is in scope. Returns the
500500
* first exceeded result so every billable surface (workflow exec, copilot,
501501
* voice, wand, enrichment, KB indexing) can gate on a single
502-
* `{ isExceeded, message }` and surface the same message.
502+
* `{ isExceeded, message }`. `scope` distinguishes a pooled cap from a per-member
503+
* cap so clients can hide an "Upgrade" affordance a capped member can't act on (a
504+
* per-member cap is only raisable by an org admin).
503505
*/
504506
export async function checkActorUsageLimits(
505507
userId: string,
506508
workspaceId?: string | null
507-
): Promise<{ isExceeded: boolean; message?: string }> {
509+
): Promise<{ isExceeded: boolean; message?: string; scope?: 'pooled' | 'member' }> {
508510
const pooled = await checkServerSideUsageLimits(userId)
509511
if (pooled.isExceeded) {
510-
return { isExceeded: true, message: pooled.message }
512+
return { isExceeded: true, message: pooled.message, scope: 'pooled' }
511513
}
512514

513515
if (workspaceId) {
514516
const member = await checkOrgMemberUsageLimit(userId, workspaceId)
515517
if (member.isExceeded) {
516-
return { isExceeded: true, message: member.message }
518+
return { isExceeded: true, message: member.message, scope: 'member' }
517519
}
518520
}
519521

0 commit comments

Comments
 (0)