From ecee977ead2ee8be1ee6b6867e834dce04980767 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 21 Jul 2026 16:09:20 -0700 Subject: [PATCH] improvement(admin): cleanup included usage settings for enterprise --- .../lib/admin/dashboard-organizations.test.ts | 32 +++- apps/sim/lib/admin/dashboard.ts | 170 +++++++++--------- .../lib/admin/organization-economics.test.ts | 8 +- apps/sim/lib/admin/organization-economics.ts | 10 +- .../api/contracts/v1/admin/dashboard.test.ts | 8 +- .../lib/api/contracts/v1/admin/dashboard.ts | 24 ++- .../billing/enterprise-credit-limits.test.ts | 11 +- .../lib/billing/enterprise-credit-limits.ts | 11 +- .../sim/lib/billing/enterprise-outbox.test.ts | 41 ++++- apps/sim/lib/billing/enterprise-outbox.ts | 26 ++- .../billing/enterprise-provisioning.test.ts | 22 +-- .../lib/billing/enterprise-provisioning.ts | 19 +- .../billing/organizations/lock-order.test.ts | 3 +- apps/sim/lib/billing/webhooks/enterprise.ts | 3 - 14 files changed, 228 insertions(+), 160 deletions(-) diff --git a/apps/sim/lib/admin/dashboard-organizations.test.ts b/apps/sim/lib/admin/dashboard-organizations.test.ts index c61b05b3da2..6f9d2362ee9 100644 --- a/apps/sim/lib/admin/dashboard-organizations.test.ts +++ b/apps/sim/lib/admin/dashboard-organizations.test.ts @@ -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(() => { diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts index d29bf4379fb..d39f6b8405b 100644 --- a/apps/sim/lib/admin/dashboard.ts +++ b/apps/sim/lib/admin/dashboard.ts @@ -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), } } @@ -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) @@ -212,7 +207,7 @@ function buildDashboardOrganizationSummary({ externalCollaboratorCount, seats, concurrencyLimit, - includedMonthlyDollars, + planAllowanceDollars, usageLimitDollars, effectiveUsageLimitDollars, prepaidBalanceDollars: Number(org.creditBalance ?? 0), @@ -227,6 +222,27 @@ function buildDashboardOrganizationSummary({ } } +export function toDashboardConfigurationUpdate( + intent: Awaited> | 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 @@ -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, @@ -570,7 +591,6 @@ export async function updateDashboardEnterpriseSeats( export async function updateDashboardOrganizationLimits( organizationId: string, values: { - includedMonthlyDollars?: number usageLimitDollars?: number concurrencyLimit?: number | null }, @@ -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') } @@ -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( @@ -625,7 +638,6 @@ export async function updateDashboardOrganizationLimits( : dollarsToCredits(values.usageLimitDollars) return { ...current, - includedMonthlyCredits: included, usageLimitCredits: configuredUsageLimit, ...(values.concurrencyLimit !== undefined ? { concurrencyLimit: values.concurrencyLimit } @@ -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() }) @@ -666,9 +674,6 @@ export async function updateDashboardOrganizationLimits( .set({ metadata: { ...metadata, - ...(subscriptionRow.plan === 'enterprise' - ? { includedMonthlyCredits: dollarsToCredits(included) } - : {}), usageLimitCredits: dollarsToCredits(configuredUsageLimit), }, }) @@ -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, @@ -753,7 +749,7 @@ export async function grantDashboardOrganizationBalance( const grantDollarDelta = toDecimal(amountDollars).toString() const usageLimitFallback = getOrganizationUsageLimitFallbackDollars({ creditBalanceDollarsBeforeGrant: org.creditBalance, - includedDollars, + planAllowanceDollars, configuredUsageLimitDollars, }) const [updated] = await tx diff --git a/apps/sim/lib/admin/organization-economics.test.ts b/apps/sim/lib/admin/organization-economics.test.ts index 2a6f49dc783..22c9413215e 100644 --- a/apps/sim/lib/admin/organization-economics.test.ts +++ b/apps/sim/lib/admin/organization-economics.test.ts @@ -8,7 +8,7 @@ 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, }) }) @@ -16,7 +16,7 @@ describe('getTeamOrganizationEconomics', () => { 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, }) }) @@ -31,7 +31,7 @@ describe('getOrganizationUsageLimitFallbackDollars', () => { expect( getOrganizationUsageLimitFallbackDollars({ creditBalanceDollarsBeforeGrant: '0.001', - includedDollars: 0, + planAllowanceDollars: 0, configuredUsageLimitDollars: 0, }) ).toBe('0.001') @@ -41,7 +41,7 @@ describe('getOrganizationUsageLimitFallbackDollars', () => { expect( getOrganizationUsageLimitFallbackDollars({ creditBalanceDollarsBeforeGrant: '1.259567', - includedDollars: 5, + planAllowanceDollars: 5, configuredUsageLimitDollars: 100, }) ).toBe('101.259567') diff --git a/apps/sim/lib/admin/organization-economics.ts b/apps/sim/lib/admin/organization-economics.ts index 063338f3967..6380686b317 100644 --- a/apps/sim/lib/admin/organization-economics.ts +++ b/apps/sim/lib/admin/organization-economics.ts @@ -4,7 +4,7 @@ import { Decimal, toDecimal } from '@/lib/billing/utils/decimal' export interface TeamOrganizationEconomics { seats: number - includedMonthlyDollars: number + planAllowanceDollars: number monthlyInvoiceAmountUsd: number } @@ -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, } } @@ -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() } diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts index 97da77abb28..85a733053ad 100644 --- a/apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts +++ b/apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts @@ -58,7 +58,7 @@ describe('admin dashboard credit grant contract', () => { externalCollaboratorCount: 0, seats: 0, concurrencyLimit: null, - includedMonthlyDollars: 0, + planAllowanceDollars: null, usageLimitDollars: 0.001, effectiveUsageLimitDollars: 0.001, prepaidBalanceDollars: 0.001, @@ -83,6 +83,12 @@ describe('admin dashboard credit grant contract', () => { expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 1.5 }).success).toBe(false) }) + it('does not expose included allowance as an editable organization control', () => { + expect(adminDashboardLimitsBodySchema.safeParse({ includedMonthlyDollars: 100 }).success).toBe( + false + ) + }) + it('accepts null to restore the deployment-wide Enterprise concurrency default', () => { expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: null }).success).toBe(true) expect( diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts index 59a0ee8079a..a7315ed4b94 100644 --- a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts +++ b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts @@ -38,7 +38,6 @@ export const adminDashboardProvisioningSchema = z.object({ organizationId: z.string(), status: z.enum(['pending', 'processing', 'dead_letter', 'awaiting_webhook', 'applied']), monthlyInvoiceAmountUsd: z.number(), - includedMonthlyDollars: creditAlignedDollarAmountSchema, usageLimitDollars: creditAlignedDollarAmountSchema, seats: z.number().int().positive(), concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT), @@ -61,7 +60,7 @@ export const adminDashboardOrganizationSummarySchema = z.object({ externalCollaboratorCount: z.number().int().min(0), seats: z.number().int().min(0), concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).nullable(), - includedMonthlyDollars: dollarAmountSchema, + planAllowanceDollars: dollarAmountSchema.nullable(), usageLimitDollars: dollarAmountSchema, effectiveUsageLimitDollars: dollarAmountSchema, prepaidBalanceDollars: dollarAmountSchema, @@ -71,6 +70,21 @@ export const adminDashboardOrganizationSummarySchema = z.object({ export const adminDashboardOrganizationDetailSchema = adminDashboardOrganizationSummarySchema.extend({ + configurationUpdate: z + .object({ + id: z.string(), + status: z.enum(['pending', 'processing', 'failed']), + requestedUsageLimitDollars: dollarAmountSchema.nullable(), + requestedSeats: z.number().int().positive().nullable(), + requestedConcurrencyLimit: z + .number() + .int() + .positive() + .max(MAX_BILLING_CONCURRENCY_LIMIT) + .nullable(), + error: z.string().nullable(), + }) + .nullable(), members: z.array( z.object({ id: z.string(), @@ -124,7 +138,6 @@ export const adminDashboardSeatsBodySchema = z.object({ export const adminDashboardLimitsBodySchema = z .object({ - includedMonthlyDollars: creditAlignedDollarAmountSchema.optional(), usageLimitDollars: creditAlignedDollarAmountSchema.optional(), concurrencyLimit: z .number() @@ -135,10 +148,7 @@ export const adminDashboardLimitsBodySchema = z .optional(), }) .refine( - (value) => - value.includedMonthlyDollars !== undefined || - value.usageLimitDollars !== undefined || - value.concurrencyLimit !== undefined, + (value) => value.usageLimitDollars !== undefined || value.concurrencyLimit !== undefined, { error: 'At least one limit must be provided' } ) diff --git a/apps/sim/lib/billing/enterprise-credit-limits.test.ts b/apps/sim/lib/billing/enterprise-credit-limits.test.ts index 6cc0036d624..0fd263b3e18 100644 --- a/apps/sim/lib/billing/enterprise-credit-limits.test.ts +++ b/apps/sim/lib/billing/enterprise-credit-limits.test.ts @@ -10,14 +10,12 @@ describe('deriveEnterpriseCreditLimits', () => { deriveEnterpriseCreditLimits({ metadata: { invoiceAmountCents: '99900', - includedMonthlyCredits: '10000', usageLimitCredits: '20000', }, monthlyPriceUsd: 999, prepaidBalanceDollars: 10, }) ).toEqual({ - includedMonthlyCredits: 10000, configuredUsageLimitCredits: 20000, prepaidCredits: 2000, effectiveUsageLimitCredits: 22000, @@ -25,20 +23,19 @@ describe('deriveEnterpriseCreditLimits', () => { }) }) - it('uses the included allowance as the base when it exceeds configuration', () => { + it('uses the configured usage limit without an invoice-derived floor', () => { expect( deriveEnterpriseCreditLimits({ metadata: { - includedMonthlyCredits: '10000', usageLimitCredits: '8000', }, monthlyPriceUsd: 999, prepaidBalanceDollars: 10, }).effectiveUsageLimitCredits - ).toBe(12000) + ).toBe(10000) }) - it('uses the legacy monthly-price allowance only when credit metadata is absent', () => { + it('defaults the usage limit to the monthly price when metadata is absent', () => { expect( deriveEnterpriseCreditLimits({ metadata: {}, @@ -46,7 +43,6 @@ describe('deriveEnterpriseCreditLimits', () => { prepaidBalanceDollars: 0, }) ).toEqual({ - includedMonthlyCredits: 10000, configuredUsageLimitCredits: 10000, prepaidCredits: 0, effectiveUsageLimitCredits: 10000, @@ -58,7 +54,6 @@ describe('deriveEnterpriseCreditLimits', () => { expect( deriveEnterpriseCreditLimits({ metadata: { - includedMonthlyCredits: '20000', usageLimitCredits: '20000', }, monthlyPriceUsd: 100, diff --git a/apps/sim/lib/billing/enterprise-credit-limits.ts b/apps/sim/lib/billing/enterprise-credit-limits.ts index fe2ec7cc89e..ef7ab5e615a 100644 --- a/apps/sim/lib/billing/enterprise-credit-limits.ts +++ b/apps/sim/lib/billing/enterprise-credit-limits.ts @@ -12,26 +12,19 @@ export function deriveEnterpriseCreditLimits({ monthlyPriceUsd, prepaidBalanceDollars, }: DeriveEnterpriseCreditLimitsInput) { - const parsedIncludedCredits = Number(metadata.includedMonthlyCredits) - const includedMonthlyCredits = Number.isFinite(parsedIncludedCredits) - ? Math.max(0, Math.round(parsedIncludedCredits)) - : dollarsToCredits(monthlyPriceUsd) const parsedUsageLimitCredits = Number(metadata.usageLimitCredits) const configuredUsageLimitCredits = Number.isFinite(parsedUsageLimitCredits) ? Math.max(0, Math.round(parsedUsageLimitCredits)) - : includedMonthlyCredits + : dollarsToCredits(monthlyPriceUsd) const prepaidBalance = toDecimal(prepaidBalanceDollars) const prepaidCredits = dollarsToCredits(prepaidBalance.toNumber()) - const effectiveUsageLimitDollars = toDecimal( - Math.max(configuredUsageLimitCredits, includedMonthlyCredits) - ) + const effectiveUsageLimitDollars = toDecimal(configuredUsageLimitCredits) .div(CREDIT_MULTIPLIER) .plus(prepaidBalance) .toString() const effectiveUsageLimitCredits = dollarsToCredits(Number(effectiveUsageLimitDollars)) return { - includedMonthlyCredits, configuredUsageLimitCredits, prepaidCredits, effectiveUsageLimitCredits, diff --git a/apps/sim/lib/billing/enterprise-outbox.test.ts b/apps/sim/lib/billing/enterprise-outbox.test.ts index 22c68e46b16..5f63af9fde1 100644 --- a/apps/sim/lib/billing/enterprise-outbox.test.ts +++ b/apps/sim/lib/billing/enterprise-outbox.test.ts @@ -35,13 +35,12 @@ import { const payload = { version: 1 as const, request: { - requestKey: 'enterprise-v2:owner-1:org-1:10000:20000:20000:5:1250', + requestKey: 'enterprise-v3:owner-1:org-1:10000:20000:5:1250', ownerUserId: 'owner-1', organizationId: 'org-1', requestedByEmail: 'admin@sim.ai', requestedByUserId: 'admin-1', invoiceAmountCents: 10000, - includedMonthlyCredits: 20000, usageLimitCredits: 20000, seats: 5, concurrencyLimit: 1250, @@ -81,7 +80,6 @@ function stripeSubscription( items: { data: Array.from({ length: options.itemCount ?? 1 }, () => item) }, metadata: { invoiceAmountCents: '10000', - includedMonthlyCredits: '20000', usageLimitCredits: '20000', seats: '5', concurrencyLimit: '1250', @@ -152,7 +150,7 @@ describe('Enterprise issuance Stripe-term correlation', () => { ...payload, request: { ...payload.request, - requestKey: 'enterprise-v2:owner-1:org-1:10000:20000:20000:5:1250:draft-collection', + requestKey: 'enterprise-v3:owner-1:org-1:10000:20000:5:1250:draft-collection', pausePaymentCollection: true, }, } @@ -180,7 +178,7 @@ describe('Enterprise issuance Stripe-term correlation', () => { ['wrong interval count', { intervalCount: 2 }], ['automatic collection', { collectionMethod: 'charge_automatically' }], ['wrong due terms', { daysUntilDue: 14 }], - ['wrong credits', { metadata: { includedMonthlyCredits: '999' } }], + ['wrong usage limit', { metadata: { usageLimitCredits: '999' } }], ['wrong seats', { metadata: { seats: '6' } }], ['wrong concurrency', { metadata: { concurrencyLimit: '999' } }], ] as const)('rejects %s', (_name, options) => { @@ -220,6 +218,12 @@ describe('Enterprise metadata intent admission state', () => { expect(state.hasUnappliedIntent).toBe(true) expect(state.effectiveSeatCapacity).toBe(7) + expect(state.configurationUpdate).toEqual({ + id: 'config-2', + status: 'pending', + requestedMetadata: { seats: 7 }, + error: null, + }) }) it('falls back to applied seats for a dead-lettered intent', async () => { @@ -241,6 +245,33 @@ describe('Enterprise metadata intent admission state', () => { expect(state.hasUnappliedIntent).toBe(false) expect(state.effectiveSeatCapacity).toBe(10) + expect(state.configurationUpdate).toEqual({ + id: 'config-2', + status: 'failed', + requestedMetadata: { seats: 7 }, + error: null, + }) + }) + + it('hides the update after the verified webhook applies its operation id', async () => { + const state = await resolveEnterpriseMetadataIntent( + executorReturning([ + { + id: 'config-2', + status: 'pending', + payload: { + subscriptionId: 'sub-local', + revision: 2, + metadata: { seats: 7 }, + }, + }, + ]), + 'sub-local', + { seats: '7', simConfigRevision: '2', simConfigOperationId: 'config-2' } + ) + + expect(state.hasUnappliedIntent).toBe(false) + expect(state.configurationUpdate).toBeNull() }) it('fails closed when the newest desired metadata payload is malformed', async () => { diff --git a/apps/sim/lib/billing/enterprise-outbox.ts b/apps/sim/lib/billing/enterprise-outbox.ts index 39362028335..93747d6033f 100644 --- a/apps/sim/lib/billing/enterprise-outbox.ts +++ b/apps/sim/lib/billing/enterprise-outbox.ts @@ -17,7 +17,6 @@ export const enterpriseProvisionRequestSchema = z.object({ requestedByEmail: z.string().min(1), requestedByUserId: z.string().nullable(), invoiceAmountCents: z.number().int().positive(), - includedMonthlyCredits: nonnegativeInteger, usageLimitCredits: nonnegativeInteger, seats: z.number().int().positive(), concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).optional(), @@ -126,7 +125,6 @@ export function enterpriseOperationMatchesStripeSubscription( price.recurring?.interval === 'month' && (price.recurring.interval_count ?? 1) === 1 && stripeMetadataInteger(metadata, 'invoiceAmountCents') === request.invoiceAmountCents && - stripeMetadataInteger(metadata, 'includedMonthlyCredits') === request.includedMonthlyCredits && stripeMetadataInteger(metadata, 'usageLimitCredits') === request.usageLimitCredits && stripeMetadataInteger(metadata, 'seats') === request.seats && (request.concurrencyLimit === undefined || @@ -209,6 +207,12 @@ export interface EnterpriseMetadataIntentState { desiredMetadata: Record hasUnappliedIntent: boolean effectiveSeatCapacity: number | null + configurationUpdate: { + id: string + status: 'pending' | 'processing' | 'failed' + requestedMetadata: Record + error: string | null + } | null } /** @@ -230,6 +234,7 @@ export async function resolveEnterpriseMetadataIntent( id: outboxEvent.id, status: outboxEvent.status, payload: outboxEvent.payload, + lastError: outboxEvent.lastError, }) .from(outboxEvent) .where( @@ -251,6 +256,7 @@ export async function resolveEnterpriseMetadataIntent( desiredMetadata: appliedMetadata, hasUnappliedIntent: false, effectiveSeatCapacity: appliedSeats, + configurationUpdate: null, } } @@ -260,7 +266,8 @@ export async function resolveEnterpriseMetadataIntent( } const appliedOperationId = appliedMetadata.simConfigOperationId - const hasUnappliedIntent = latest.status !== 'dead_letter' && appliedOperationId !== latest.id + const operationApplied = appliedOperationId === latest.id + const hasUnappliedIntent = latest.status !== 'dead_letter' && !operationApplied const desiredMetadata = hasUnappliedIntent ? parsed.data.metadata : appliedMetadata const desiredSeats = positiveInteger(parsed.data.metadata.seats) const effectiveSeatCapacity = hasUnappliedIntent @@ -276,5 +283,18 @@ export async function resolveEnterpriseMetadataIntent( desiredMetadata, hasUnappliedIntent, effectiveSeatCapacity, + configurationUpdate: operationApplied + ? null + : { + id: latest.id, + status: + latest.status === 'dead_letter' + ? 'failed' + : latest.status === 'processing' + ? 'processing' + : 'pending', + requestedMetadata: parsed.data.metadata, + error: latest.status === 'dead_letter' ? (latest.lastError ?? null) : null, + }, } } diff --git a/apps/sim/lib/billing/enterprise-provisioning.test.ts b/apps/sim/lib/billing/enterprise-provisioning.test.ts index 45667323064..d01e15d1964 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.test.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.test.ts @@ -132,13 +132,12 @@ function operationPayload(overrides: Record = {}) { return { version: 1 as const, request: { - requestKey: 'enterprise-v2:owner-1:org-1:12500:20000:24000:12:1250', + requestKey: 'enterprise-v3:owner-1:org-1:12500:24000:12:1250', ownerUserId: 'owner-1', organizationId: 'org-1', requestedByEmail: 'admin@sim.ai', requestedByUserId: 'admin-1', invoiceAmountCents: 12500, - includedMonthlyCredits: 20000, usageLimitCredits: 24000, seats: 12, concurrencyLimit: 1250, @@ -160,7 +159,7 @@ function context() { } describe('Enterprise issuance serialization decisions', () => { - it('derives included usage from invoice amount in the request key', () => { + it('includes the configured or invoice-defaulted usage limit in the request key', () => { const input = { ownerUserId: 'owner-1', monthlyInvoiceAmountUsd: 125, @@ -171,17 +170,17 @@ describe('Enterprise issuance serialization decisions', () => { } expect(buildEnterpriseProvisioningRequestKey(input, 'org-1')).toBe( - 'enterprise-v2:owner-1:org-1:12500:25000:24000:12' + 'enterprise-v3:owner-1:org-1:12500:24000:12' ) expect( buildEnterpriseProvisioningRequestKey({ ...input, concurrencyLimit: 1250 }, 'org-1') - ).toBe('enterprise-v2:owner-1:org-1:12500:25000:24000:12:1250') + ).toBe('enterprise-v3:owner-1:org-1:12500:24000:12:1250') expect( buildEnterpriseProvisioningRequestKey({ ...input, pausePaymentCollection: true }, 'org-1') - ).toBe('enterprise-v2:owner-1:org-1:12500:25000:24000:12:draft-collection') + ).toBe('enterprise-v3:owner-1:org-1:12500:24000:12:draft-collection') expect( buildEnterpriseProvisioningRequestKey({ ...input, usageLimitCredits: undefined }, 'org-1') - ).toBe('enterprise-v2:owner-1:org-1:12500:25000:25000:12') + ).toBe('enterprise-v3:owner-1:org-1:12500:25000:12') }) it('deduplicates an identical unresolved request to the existing outbox operation', () => { @@ -197,7 +196,7 @@ describe('Enterprise issuance serialization decisions', () => { it('rejects a different request while the existing operation is unresolved', () => { expect(() => decideEnterpriseProvisioningIssue( - 'enterprise-v2:different-request', + 'enterprise-v3:different-request', [{ id: 'operation-1', payload: operationPayload() }], [] ) @@ -324,7 +323,6 @@ describe('Enterprise issuance outbox handler', () => { metadata: expect.objectContaining({ enterpriseOperationId: 'operation-1', referenceId: 'org-1', - includedMonthlyCredits: '20000', usageLimitCredits: '24000', seats: '12', concurrencyLimit: '1250', @@ -379,7 +377,7 @@ describe('Enterprise issuance outbox handler', () => { const pausedPayload = operationPayload({ request: { ...operationPayload().request, - requestKey: 'enterprise-v2:owner-1:org-1:12500:20000:24000:12:1250:draft-collection', + requestKey: 'enterprise-v3:owner-1:org-1:12500:24000:12:1250:draft-collection', pausePaymentCollection: true, }, }) @@ -407,7 +405,7 @@ describe('Enterprise issuance outbox handler', () => { const pausedPayload = operationPayload({ request: { ...operationPayload().request, - requestKey: 'enterprise-v2:owner-1:org-1:12500:20000:24000:12:1250:draft-collection', + requestKey: 'enterprise-v3:owner-1:org-1:12500:24000:12:1250:draft-collection', pausePaymentCollection: true, }, }) @@ -496,7 +494,6 @@ describe('Enterprise metadata outbox handler', () => { plan: 'enterprise', referenceId: 'org-1', seats: 15, - includedMonthlyCredits: 30000, usageLimitCredits: 35000, concurrencyLimit: 1250, }, @@ -524,7 +521,6 @@ describe('Enterprise metadata outbox handler', () => { { metadata: expect.objectContaining({ seats: '15', - includedMonthlyCredits: '30000', concurrencyLimit: '1250', simConfigRevision: '4', simConfigOperationId: 'metadata-event-1', diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts index b126748e8a8..368f7135305 100644 --- a/apps/sim/lib/billing/enterprise-provisioning.ts +++ b/apps/sim/lib/billing/enterprise-provisioning.ts @@ -249,7 +249,6 @@ export interface EnterpriseProvisioningView { organizationId: string status: EnterpriseOperationStatus monthlyInvoiceAmountUsd: number - includedMonthlyCredits: number usageLimitCredits: number seats: number concurrencyLimit: number @@ -276,19 +275,19 @@ function slugifyOrganizationName(name: string, organizationId: string): string { return `${base || 'organization'}-${organizationId.slice(-8)}` } -/** Builds a commercial-terms key while preserving legacy keys when new terms are omitted. */ +/** Builds a deterministic key from every Enterprise commercial term. */ export function buildEnterpriseProvisioningRequestKey( input: IssueEnterpriseProvisioningInput, organizationId: string ): string { - const includedMonthlyCredits = dollarsToCredits(input.monthlyInvoiceAmountUsd) + const usageLimitCredits = + input.usageLimitCredits ?? dollarsToCredits(input.monthlyInvoiceAmountUsd) const requestTerms: Array = [ - 'enterprise-v2', + 'enterprise-v3', input.ownerUserId, organizationId, Math.round(input.monthlyInvoiceAmountUsd * 100), - includedMonthlyCredits, - input.usageLimitCredits ?? includedMonthlyCredits, + usageLimitCredits, input.seats, ] if (input.concurrencyLimit !== undefined) requestTerms.push(input.concurrencyLimit) @@ -308,7 +307,6 @@ function toEnterpriseProvisioningView( organizationId: request.organizationId, status: deriveEnterpriseOperationStatus(row.status, payload), monthlyInvoiceAmountUsd: request.invoiceAmountCents / 100, - includedMonthlyCredits: request.includedMonthlyCredits, usageLimitCredits: request.usageLimitCredits, seats: request.seats, concurrencyLimit: getBillingConcurrencyLimit('enterprise', request.concurrencyLimit), @@ -423,7 +421,7 @@ export async function issueEnterpriseProvisioning( 'Monthly invoice amount must be at least $0.01 and use whole cents' ) } - const includedMonthlyCredits = dollarsToCredits(input.monthlyInvoiceAmountUsd) + const defaultUsageLimitCredits = dollarsToCredits(input.monthlyInvoiceAmountUsd) if ( input.concurrencyLimit !== undefined && parseBillingConcurrencyLimit(input.concurrencyLimit) !== input.concurrencyLimit @@ -550,8 +548,7 @@ export async function issueEnterpriseProvisioning( requestedByEmail: input.requestedByEmail, requestedByUserId: input.requestedByUserId, invoiceAmountCents, - includedMonthlyCredits, - usageLimitCredits: input.usageLimitCredits ?? includedMonthlyCredits, + usageLimitCredits: input.usageLimitCredits ?? defaultUsageLimitCredits, seats: input.seats, ...(input.concurrencyLimit !== undefined ? { concurrencyLimit: input.concurrencyLimit } : {}), pausePaymentCollection: input.pausePaymentCollection ?? false, @@ -580,7 +577,6 @@ export async function issueEnterpriseProvisioning( metadata: { organizationId: view.organizationId, invoiceAmountCents: Math.round(view.monthlyInvoiceAmountUsd * 100), - includedMonthlyCredits: view.includedMonthlyCredits, usageLimitCredits: view.usageLimitCredits, seats: view.seats, concurrencyLimit: view.concurrencyLimit, @@ -826,7 +822,6 @@ export const provisionEnterpriseInStripe: OutboxHandler = async (rawPay enterpriseOperationId: context.eventId, invoiceAmountCents: request.invoiceAmountCents.toString(), monthlyPrice: (request.invoiceAmountCents / 100).toFixed(2), - includedMonthlyCredits: request.includedMonthlyCredits.toString(), usageLimitCredits: request.usageLimitCredits.toString(), seats: request.seats.toString(), ...(request.concurrencyLimit !== undefined diff --git a/apps/sim/lib/billing/organizations/lock-order.test.ts b/apps/sim/lib/billing/organizations/lock-order.test.ts index f430ed9b040..f06493cdcab 100644 --- a/apps/sim/lib/billing/organizations/lock-order.test.ts +++ b/apps/sim/lib/billing/organizations/lock-order.test.ts @@ -310,13 +310,12 @@ describe('organization ownership transfer reservation', () => { payload: { version: 1, request: { - requestKey: 'enterprise-v2:owner-1:org-1:10000:20000:20000:5', + requestKey: 'enterprise-v3:owner-1:org-1:10000:20000:5', ownerUserId: 'owner-1', organizationId: 'org-1', requestedByEmail: 'admin@sim.ai', requestedByUserId: 'admin-1', invoiceAmountCents: 10000, - includedMonthlyCredits: 20000, usageLimitCredits: 20000, seats: 5, }, diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts index 3ee71a7b516..1c3689b944e 100644 --- a/apps/sim/lib/billing/webhooks/enterprise.ts +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -331,7 +331,6 @@ async function reconcileManualEnterpriseSubscription( operationNewlyApplied, hasCorrelatedOperation, subscriptionNewlyInserted, - includedMonthlyCredits, configuredUsageLimitCredits, prepaidCredits, effectiveUsageLimitCredits, @@ -344,7 +343,6 @@ async function reconcileManualEnterpriseSubscription( plan: subscriptionRow.plan, status: subscriptionRow.status, monthlyPrice, - includedMonthlyCredits, effectiveUsageLimitCredits, prepaidCredits, seats, @@ -391,7 +389,6 @@ async function reconcileManualEnterpriseSubscription( stripeSubscriptionId: stripeSubscription.id, seats, monthlyPrice, - includedMonthlyCredits, configuredUsageLimitCredits, effectiveUsageLimitCredits, prepaidCredits,