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
32 changes: 31 additions & 1 deletion apps/sim/lib/admin/dashboard-organizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,37 @@ vi.mock('@/lib/core/idempotency/transaction', () => ({
}))
vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: vi.fn() }))

import { listDashboardOrganizations } from '@/lib/admin/dashboard'
import { listDashboardOrganizations, toDashboardConfigurationUpdate } from '@/lib/admin/dashboard'

describe('toDashboardConfigurationUpdate', () => {
it('converts the pending Stripe metadata intent without replacing applied values', () => {
expect(
toDashboardConfigurationUpdate({
latestRevision: 2,
desiredMetadata: {},
hasUnappliedIntent: true,
effectiveSeatCapacity: 20,
configurationUpdate: {
id: 'config-2',
status: 'pending',
requestedMetadata: {
usageLimitCredits: 10_000_000,
seats: 20,
concurrencyLimit: 50,
},
error: null,
},
})
).toEqual({
id: 'config-2',
status: 'pending',
requestedUsageLimitDollars: 50_000,
requestedSeats: 20,
requestedConcurrencyLimit: 50,
error: null,
})
})
})

describe('listDashboardOrganizations', () => {
beforeEach(() => {
Expand Down
170 changes: 83 additions & 87 deletions apps/sim/lib/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,9 @@ interface DashboardOrganizationSummaryInput {
}

export function toDashboardProvisioning(view: EnterpriseProvisioningView) {
const { includedMonthlyCredits, usageLimitCredits, ...rest } = view
const { usageLimitCredits, ...rest } = view
return {
...rest,
includedMonthlyDollars: creditsToDollars(includedMonthlyCredits),
usageLimitDollars: creditsToDollars(usageLimitCredits),
}
}
Expand All @@ -172,11 +171,7 @@ function buildDashboardOrganizationSummary({
}: DashboardOrganizationSummaryInput) {
const metadata = metadataRecord(latestSubscription?.metadata)
const teamEconomics = getTeamOrganizationEconomics(latestSubscription?.plan, memberCount)
const includedMonthlyDollars =
teamEconomics?.includedMonthlyDollars ??
creditsToDollars(
Math.max(0, Math.round(metadataNumber(metadata, 'includedMonthlyCredits') ?? 0))
)
const planAllowanceDollars = teamEconomics?.planAllowanceDollars ?? null
const invoiceAmountCents = metadataNumber(metadata, 'invoiceAmountCents')
const monthlyPrice = metadataNumber(metadata, 'monthlyPrice')
const effectiveUsageLimitDollars = Number(org.orgUsageLimit ?? 0)
Expand Down Expand Up @@ -212,7 +207,7 @@ function buildDashboardOrganizationSummary({
externalCollaboratorCount,
seats,
concurrencyLimit,
includedMonthlyDollars,
planAllowanceDollars,
usageLimitDollars,
effectiveUsageLimitDollars,
prepaidBalanceDollars: Number(org.creditBalance ?? 0),
Expand All @@ -227,6 +222,27 @@ function buildDashboardOrganizationSummary({
}
}

export function toDashboardConfigurationUpdate(
intent: Awaited<ReturnType<typeof resolveEnterpriseMetadataIntent>> | null
) {
const update = intent?.configurationUpdate
if (!update) return null
const metadata = update.requestedMetadata
const usageLimitCredits = metadataNumber(metadata, 'usageLimitCredits')
const seats = metadataNumber(metadata, 'seats')
const concurrencyLimit = metadataNumber(metadata, 'concurrencyLimit')

return {
id: update.id,
status: update.status,
requestedUsageLimitDollars:
usageLimitCredits === null ? null : creditsToDollars(usageLimitCredits),
requestedSeats: seats === null ? null : Math.round(seats),
requestedConcurrencyLimit: concurrencyLimit === null ? null : Math.round(concurrencyLimit),
error: update.error,
}
}

export async function listDashboardUsers({ search, limit, offset }: PaginationInput) {
const trimmed = search.trim()
// Mirror Better Auth's active-ban semantics: permanent bans and temporary
Expand Down Expand Up @@ -444,60 +460,65 @@ export async function listDashboardOrganizations({ search, limit, offset }: Pagi
export async function getDashboardOrganization(organizationId: string) {
const summary = await getDashboardOrganizationSummary(organizationId)
if (!summary) return null
const [memberRows, externalRows, workspaceRows, limitRows] = await Promise.all([
db
.select({
id: member.id,
userId: user.id,
name: user.name,
email: user.email,
role: member.role,
})
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.where(eq(member.organizationId, organizationId))
.orderBy(user.name),
db
.select({
userId: user.id,
name: user.name,
email: user.email,
workspaceCount: countDistinct(workspace.id),
})
.from(permissions)
.innerJoin(user, eq(user.id, permissions.userId))
.innerJoin(
workspace,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(workspace.organizationId, organizationId)
const { subscription: subscriptionRow, ...base } = summary
const [memberRows, externalRows, workspaceRows, limitRows, configurationIntent] =
await Promise.all([
db
.select({
id: member.id,
userId: user.id,
name: user.name,
email: user.email,
role: member.role,
})
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.where(eq(member.organizationId, organizationId))
.orderBy(user.name),
db
.select({
userId: user.id,
name: user.name,
email: user.email,
workspaceCount: countDistinct(workspace.id),
})
.from(permissions)
.innerJoin(user, eq(user.id, permissions.userId))
.innerJoin(
workspace,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspace.id),
eq(workspace.organizationId, organizationId)
)
)
)
.leftJoin(
member,
and(eq(member.userId, permissions.userId), eq(member.organizationId, organizationId))
)
.where(and(isNull(member.id), isNull(workspace.archivedAt)))
.groupBy(user.id, user.name, user.email)
.orderBy(user.name),
db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(eq(workspace.organizationId, organizationId))
.orderBy(workspace.name),
db
.select({
userId: organizationMemberUsageLimit.userId,
limit: organizationMemberUsageLimit.usageLimit,
})
.from(organizationMemberUsageLimit)
.where(eq(organizationMemberUsageLimit.organizationId, organizationId)),
])
.leftJoin(
member,
and(eq(member.userId, permissions.userId), eq(member.organizationId, organizationId))
)
.where(and(isNull(member.id), isNull(workspace.archivedAt)))
.groupBy(user.id, user.name, user.email)
.orderBy(user.name),
db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(eq(workspace.organizationId, organizationId))
.orderBy(workspace.name),
db
.select({
userId: organizationMemberUsageLimit.userId,
limit: organizationMemberUsageLimit.usageLimit,
})
.from(organizationMemberUsageLimit)
.where(eq(organizationMemberUsageLimit.organizationId, organizationId)),
subscriptionRow?.plan === 'enterprise'
? resolveEnterpriseMetadataIntent(db, subscriptionRow.id, subscriptionRow.metadata)
: Promise.resolve(null),
])
const limits = new Map(limitRows.map((row) => [row.userId, Number(row.limit)]))
const { subscription: subscriptionRow, ...base } = summary
return {
...base,
configurationUpdate: toDashboardConfigurationUpdate(configurationIntent),
members: memberRows.map((row) => ({
...row,
usageLimitDollars: limits.get(row.userId) ?? null,
Expand Down Expand Up @@ -570,7 +591,6 @@ export async function updateDashboardEnterpriseSeats(
export async function updateDashboardOrganizationLimits(
organizationId: string,
values: {
includedMonthlyDollars?: number
usageLimitDollars?: number
concurrencyLimit?: number | null
},
Expand All @@ -597,9 +617,6 @@ export async function updateDashboardOrganizationLimits(
.for('update')
.limit(1)
const metadata = metadataRecord(subscriptionRow?.metadata)
if (values.includedMonthlyDollars !== undefined && subscriptionRow?.plan !== 'enterprise') {
throw new Error('Included allowance is editable only for Enterprise organizations')
}
if (values.concurrencyLimit !== undefined && subscriptionRow?.plan !== 'enterprise') {
throw new Error('Concurrency is editable only for Enterprise organizations')
}
Expand All @@ -612,10 +629,6 @@ export async function updateDashboardOrganizationLimits(
subscriptionId: subscriptionRow.id,
appliedMetadata: subscriptionRow.metadata,
buildDesiredMetadata: (current) => {
const included =
values.includedMonthlyDollars === undefined
? Math.round(metadataNumber(current, 'includedMonthlyCredits') ?? 0)
: dollarsToCredits(values.includedMonthlyDollars)
const configuredUsageLimit =
values.usageLimitDollars === undefined
? Math.round(
Expand All @@ -625,7 +638,6 @@ export async function updateDashboardOrganizationLimits(
: dollarsToCredits(values.usageLimitDollars)
return {
...current,
includedMonthlyCredits: included,
usageLimitCredits: configuredUsageLimit,
...(values.concurrencyLimit !== undefined
? { concurrencyLimit: values.concurrencyLimit }
Expand All @@ -644,18 +656,14 @@ export async function updateDashboardOrganizationLimits(
subscriptionRow?.plan,
memberCountRow?.value ?? 0
)
const included =
values.includedMonthlyDollars ??
(teamEconomics
? teamEconomics.includedMonthlyDollars
: creditsToDollars(Math.round(metadataNumber(metadata, 'includedMonthlyCredits') ?? 0)))
const planAllowance = teamEconomics?.planAllowanceDollars ?? 0
const prepaid = Number(org.creditBalance)
const configuredUsageLimit =
values.usageLimitDollars ??
(metadataNumber(metadata, 'usageLimitCredits') === null
? Number(org.orgUsageLimit ?? 0)
: creditsToDollars(metadataNumber(metadata, 'usageLimitCredits') ?? 0))
const effective = Math.max(configuredUsageLimit, included + prepaid)
const effective = Math.max(configuredUsageLimit, planAllowance + prepaid)
await tx
.update(organization)
.set({ orgUsageLimit: effective.toString(), updatedAt: new Date() })
Expand All @@ -666,9 +674,6 @@ export async function updateDashboardOrganizationLimits(
.set({
metadata: {
...metadata,
...(subscriptionRow.plan === 'enterprise'
? { includedMonthlyCredits: dollarsToCredits(included) }
: {}),
usageLimitCredits: dollarsToCredits(configuredUsageLimit),
},
})
Expand Down Expand Up @@ -731,16 +736,7 @@ export async function grantDashboardOrganizationBalance(
subscriptionRow?.plan,
memberCountRow?.value ?? 0
)
const includedDollars = teamEconomics
? teamEconomics.includedMonthlyDollars
: creditsToDollars(
Math.round(
metadataNumber(
metadataRecord(subscriptionRow?.metadata),
'includedMonthlyCredits'
) ?? 0
)
)
const planAllowanceDollars = teamEconomics?.planAllowanceDollars ?? 0
const subscriptionMetadata = metadataRecord(subscriptionRow?.metadata)
const configuredUsageLimitCredits = metadataNumber(
subscriptionMetadata,
Expand All @@ -753,7 +749,7 @@ export async function grantDashboardOrganizationBalance(
const grantDollarDelta = toDecimal(amountDollars).toString()
Comment thread
icecrasher321 marked this conversation as resolved.
const usageLimitFallback = getOrganizationUsageLimitFallbackDollars({
creditBalanceDollarsBeforeGrant: org.creditBalance,
includedDollars,
planAllowanceDollars,
configuredUsageLimitDollars,
})
const [updated] = await tx
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/lib/admin/organization-economics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ describe('getTeamOrganizationEconomics', () => {
it('derives the pooled Pro allowance and invoice from internal seats', () => {
expect(getTeamOrganizationEconomics('team_6000', 3)).toEqual({
seats: 3,
includedMonthlyDollars: 90,
planAllowanceDollars: 90,
monthlyInvoiceAmountUsd: 75,
})
})

it('derives the pooled Max allowance and invoice from internal seats', () => {
expect(getTeamOrganizationEconomics('team_25000', 2)).toEqual({
seats: 2,
includedMonthlyDollars: 250,
planAllowanceDollars: 250,
monthlyInvoiceAmountUsd: 200,
})
})
Expand All @@ -31,7 +31,7 @@ describe('getOrganizationUsageLimitFallbackDollars', () => {
expect(
getOrganizationUsageLimitFallbackDollars({
creditBalanceDollarsBeforeGrant: '0.001',
includedDollars: 0,
planAllowanceDollars: 0,
configuredUsageLimitDollars: 0,
})
).toBe('0.001')
Expand All @@ -41,7 +41,7 @@ describe('getOrganizationUsageLimitFallbackDollars', () => {
expect(
getOrganizationUsageLimitFallbackDollars({
creditBalanceDollarsBeforeGrant: '1.259567',
includedDollars: 5,
planAllowanceDollars: 5,
configuredUsageLimitDollars: 100,
})
).toBe('101.259567')
Expand Down
10 changes: 5 additions & 5 deletions apps/sim/lib/admin/organization-economics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Decimal, toDecimal } from '@/lib/billing/utils/decimal'

export interface TeamOrganizationEconomics {
seats: number
includedMonthlyDollars: number
planAllowanceDollars: number
monthlyInvoiceAmountUsd: number
}

Expand All @@ -17,7 +17,7 @@ export function getTeamOrganizationEconomics(
const seats = Math.max(0, Math.trunc(internalMemberCount))
return {
seats,
includedMonthlyDollars: (getPlanTierCredits(plan) * seats) / CREDIT_MULTIPLIER,
planAllowanceDollars: (getPlanTierCredits(plan) * seats) / CREDIT_MULTIPLIER,
monthlyInvoiceAmountUsd: getPlanTierDollars(plan) * seats,
}
}
Expand All @@ -29,15 +29,15 @@ export function getTeamOrganizationEconomics(
*/
export function getOrganizationUsageLimitFallbackDollars(params: {
creditBalanceDollarsBeforeGrant: string | number
includedDollars: number
planAllowanceDollars: number
configuredUsageLimitDollars: number | null
}): string {
const configuredUsageLimitDollars =
params.configuredUsageLimitDollars === null
? toDecimal(0)
: toDecimal(params.configuredUsageLimitDollars)
const includedUsageLimitDollars = toDecimal(params.includedDollars)
return Decimal.max(configuredUsageLimitDollars, includedUsageLimitDollars)
const planAllowanceDollars = toDecimal(params.planAllowanceDollars)
return Decimal.max(configuredUsageLimitDollars, planAllowanceDollars)
.plus(toDecimal(params.creditBalanceDollarsBeforeGrant))
.toString()
}
Loading
Loading