Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/sim/app/api/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
if (!parsed.success) return parsed.response

const { context, id: contextId, includeOrg } = parsed.data.query
const { context, id: contextId, includeOrg, memberLimit, memberOffset } = parsed.data.query
if (context === 'organization' && !contextId) {
return NextResponse.json(
{ error: 'Organization ID is required when context=organization' },
Expand Down Expand Up @@ -190,7 +190,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
billingStatus,
upgradeWorkspaceId,
] = await Promise.all([
getOrganizationBillingData(organizationId, dbReplica),
getOrganizationBillingData(organizationId, dbReplica, {
limit: memberLimit,
offset: memberOffset,
}),
getOrganizationSubscription(organizationId, { executor: dbReplica, onError: 'throw' }),
dbReplica
.select({ id: organizationTable.id, name: organizationTable.name })
Expand Down Expand Up @@ -254,6 +257,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
rawBillingData?.billingPeriodEnd?.toISOString() ??
displayedSubscription?.periodEnd?.toISOString() ??
null,
membersTotal: rawBillingData?.membersTotal ?? 0,
memberPagination: rawBillingData?.memberPagination ?? {
total: 0,
limit: memberLimit,
offset: memberOffset,
hasMore: false,
},
members:
rawBillingData?.members.map((organizationMember) => ({
...organizationMember,
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/api/billing/update-cost/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const ACCOUNT_BILLING_DECISION = {
billingPeriod: {
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
source: 'reporting' as const,
},
}

Expand Down Expand Up @@ -418,6 +419,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
billingPeriod: {
start: new Date('2026-07-01T00:00:00.000Z'),
end: new Date('2026-08-01T00:00:00.000Z'),
source: 'reporting',
},
})
)
Expand All @@ -431,6 +433,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expectedBillingPeriod: {
start: new Date('2026-07-01T00:00:00.000Z'),
end: new Date('2026-08-01T00:00:00.000Z'),
source: 'reporting',
},
}
)
Expand Down Expand Up @@ -660,6 +663,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expectedBillingPeriod: {
start: new Date('2026-07-01T00:00:00.000Z'),
end: new Date('2026-08-01T00:00:00.000Z'),
source: 'reporting',
},
}
)
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/billing/update-cost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
billingPeriod: {
start: new Date(accountDecision.billingPeriod.start),
end: new Date(accountDecision.billingPeriod.end),
...(accountDecision.billingPeriod.source
? { source: accountDecision.billingPeriod.source }
: {}),
},
}
: undefined
Expand Down
16 changes: 14 additions & 2 deletions apps/sim/app/api/copilot/api-keys/validate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const ATTRIBUTION = {
billingPeriod: {
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
source: 'reporting' as const,
},
payerSubscription: null,
}
Expand All @@ -63,6 +64,7 @@ const ACCOUNT_BILLING_DECISION = {
billingPeriod: {
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
source: 'reporting' as const,
},
}

Expand Down Expand Up @@ -174,6 +176,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
billingPeriod: {
start: new Date(ACCOUNT_BILLING_DECISION.billingPeriod.start),
end: new Date(ACCOUNT_BILLING_DECISION.billingPeriod.end),
source: ACCOUNT_BILLING_DECISION.billingPeriod.source,
},
})
mockGetUserEntityPermissions.mockResolvedValue('read')
Expand Down Expand Up @@ -387,11 +390,16 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
)

expect(res.status).toBe(200)
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith(
'user-1',
ACCOUNT_SUBSCRIPTION,
expect.objectContaining({ billingEntity: ACCOUNT_BILLING_DECISION.billingEntity })
)
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
expect(mockResolveBillingAttribution).not.toHaveBeenCalled()
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
expect(mockGetWorkspaceBillingSettings).not.toHaveBeenCalled()
expect(mockSerializeAccountBillingDecisionHeader).toHaveBeenCalledWith(ACCOUNT_BILLING_DECISION)
expect(res.headers.get('x-sim-billing-account-decision')).toBe('serialized-account-decision')
})

Expand All @@ -407,7 +415,11 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
)

expect(res.status).toBe(200)
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith(
'user-1',
ACCOUNT_SUBSCRIPTION,
expect.objectContaining({ billingEntity: ACCOUNT_BILLING_DECISION.billingEntity })
)
})

it('fails direct-v1 admission closed when its payer cannot be resolved', async () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/app/api/copilot/api-keys/validate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
onError: 'throw',
})
const billingContext = deriveBillingContext(admission.userId, subscription)
const usage = await checkServerSideUsageLimits(admission.userId, subscription)
const usage = await checkServerSideUsageLimits(admission.userId, subscription, billingContext)
return {
isExceeded: usage.isExceeded,
currentUsage: usage.currentUsage,
Expand All @@ -181,6 +181,9 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
billingPeriod: {
start: billingContext.billingPeriod.start.toISOString(),
end: billingContext.billingPeriod.end.toISOString(),
...(billingContext.billingPeriod.source
? { source: billingContext.billingPeriod.source }
: {}),
},
},
}
Expand Down
31 changes: 11 additions & 20 deletions apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organi
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization'
import {
removeExternalUserFromOrganizationWorkspaces,
removeUserFromOrganization,
Expand Down Expand Up @@ -99,31 +98,23 @@ export const GET = withRouteHandler(
.where(eq(userStats.userId, memberId))
.limit(1)

const computed = await getUserUsageData(memberId, dbReplica)

if (usageData.length > 0) {
// currentPeriodCost is only a baseline; add this member's attributed
// usage_log for the period. (getUserUsageData returns the org POOL for
// org-scoped members, so it can't supply the per-member figure.)
const memberLedger =
(
await getOrgMemberLedgerByUser(
organizationId,
computed.billingPeriodStart && computed.billingPeriodEnd
? { start: computed.billingPeriodStart, end: computed.billingPeriodEnd }
: null,
dbReplica
)
).get(memberId) ?? 0
const { billingPeriod, includeLegacyBaseline, usageByUser } =
await getOrganizationMemberUsageSnapshot(organizationId, {
executor: dbReplica,
userIds: [memberId],
})
const memberLedger = usageByUser.get(memberId) ?? 0
memberData = {
...memberData,
usage: {
...usageData[0],
currentPeriodCost: (
Number(usageData[0].currentPeriodCost ?? 0) + memberLedger
(includeLegacyBaseline ? Number(usageData[0].currentPeriodCost ?? 0) : 0) +
memberLedger
).toString(),
billingPeriodStart: computed.billingPeriodStart,
billingPeriodEnd: computed.billingPeriodEnd,
billingPeriodStart: billingPeriod?.start ?? null,
billingPeriodEnd: billingPeriod?.end ?? null,
},
} as typeof memberData & {
usage: (typeof usageData)[0] & {
Expand Down
117 changes: 61 additions & 56 deletions apps/sim/app/api/organizations/[id]/members/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { db } from '@sim/db'
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
import { member, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { and, eq, inArray } from 'drizzle-orm'
import { and, count, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
organizationMemberQuerySchema,
organizationParamsSchema,
} from '@/lib/api/contracts/organization'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('OrganizationMembersAPI')
Expand Down Expand Up @@ -47,6 +46,7 @@ export const GET = withRouteHandler(
{ status: 400 }
)
}
const { limit, offset } = queryResult.data
const includeUsage = queryResult.data.include === 'usage'

// Verify user has access to this organization
Expand All @@ -67,7 +67,7 @@ export const GET = withRouteHandler(
const hasAdminAccess = isOrgAdminRole(userRole)

// Get organization members
const query = db
const memberPageQuery = db
.select({
id: member.id,
userId: member.userId,
Expand All @@ -80,82 +80,87 @@ export const GET = withRouteHandler(
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.organizationId, organizationId))
.orderBy(user.name, user.id)
.limit(limit)
.offset(offset)

const totalQuery = db
.select({ value: count() })
.from(member)
.where(eq(member.organizationId, organizationId))

// Include usage data if requested and user has admin access
if (includeUsage && hasAdminAccess) {
const base = await db
.select({
id: member.id,
userId: member.userId,
organizationId: member.organizationId,
role: member.role,
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
const [base, totalRows] = await Promise.all([
db
.select({
id: member.id,
userId: member.userId,
organizationId: member.organizationId,
role: member.role,
createdAt: member.createdAt,
userName: user.name,
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(user.id, userStats.userId))
.where(eq(member.organizationId, organizationId))
.orderBy(user.name, user.id)
.limit(limit)
.offset(offset),
totalQuery,
])

const { billingPeriod, includeLegacyBaseline, usageByUser } =
await getOrganizationMemberUsageSnapshot(organizationId, {
userIds: base.map((row) => row.userId),
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(user.id, userStats.userId))
.where(eq(member.organizationId, organizationId))

// The billing period is the same for every member — it comes from
// whichever subscription covers them. Fetch once and attach to
// every row instead of calling `getUserUsageData` per-member,
// which would run an O(N) pooled query for each of N rows.
const [orgSub] = await db
.select({
periodStart: subscriptionTable.periodStart,
periodEnd: subscriptionTable.periodEnd,
})
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)

const billingPeriodStart = orgSub?.periodStart ?? null
const billingPeriodEnd = orgSub?.periodEnd ?? null

// currentPeriodCost is only a baseline; add each member's attributed
// usage_log for the period (batched, one query) so the roster shows real
// usage rather than the frozen baseline.
const usageByUser = await getOrgMemberLedgerByUser(
organizationId,
billingPeriodStart && billingPeriodEnd
? { start: billingPeriodStart, end: billingPeriodEnd }
: null
)
const billingPeriodStart = billingPeriod?.start ?? null
const billingPeriodEnd = billingPeriod?.end ?? null

const membersWithUsage = base.map((row) => ({
...row,
currentPeriodCost: (
Number(row.currentPeriodCost ?? 0) + (usageByUser.get(row.userId) ?? 0)
(includeLegacyBaseline ? Number(row.currentPeriodCost ?? 0) : 0) +
(usageByUser.get(row.userId) ?? 0)
).toString(),
billingPeriodStart,
billingPeriodEnd,
}))

const total = totalRows[0]?.value ?? 0
return NextResponse.json({
success: true,
data: membersWithUsage,
total: membersWithUsage.length,
total,
pagination: {
total,
limit,
offset,
hasMore: offset + membersWithUsage.length < total,
},
userRole,
hasAdminAccess,
})
}

const members = await query
const [members, totalRows] = await Promise.all([memberPageQuery, totalQuery])
const total = totalRows[0]?.value ?? 0

return NextResponse.json({
success: true,
data: members,
total: members.length,
total,
pagination: {
total,
limit,
offset,
hasMore: offset + members.length < total,
},
userRole,
hasAdminAccess,
})
Expand Down
Loading
Loading