diff --git a/apps/docs/content/docs/en/platform/costs.mdx b/apps/docs/content/docs/en/platform/costs.mdx
index ac52de4ecad..26c47fe0898 100644
--- a/apps/docs/content/docs/en/platform/costs.mdx
+++ b/apps/docs/content/docs/en/platform/costs.mdx
@@ -332,7 +332,18 @@ Team and Enterprise plans unlock shared workspaces that belong to your organizat
| **Max** | 300 | 2,500 |
| **Enterprise** | 600 | 5,000 |
-Max (individual) shares the same rate limits as team plans. Team plans (Pro or Max for Teams) use the Max-tier rate limits.
+Rate limits follow the paid tier: Pro and Pro for Teams share the Pro limits, while Max and Max for Teams share the Max limits.
+
+### Concurrent Executions
+
+| Plan | Concurrent Executions |
+|------|-----------------------|
+| **Free** | 10 |
+| **Pro** | 50 |
+| **Max** | 200 |
+| **Enterprise** | 1,000 by default (customizable) |
+
+The concurrency limit applies per billing account. For organization-based plans, the limit is shared by all workspaces, members, and API keys under that organization. Teams plans follow their tier: Pro for Teams uses the Pro limit and Max for Teams uses the Max limit. An admitted async execution holds a slot while queued and running; synchronous executions hold one while running. Workflow-in-workflow blocks execute inside the parent run and do not consume another concurrency slot.
### File Storage
@@ -343,7 +354,7 @@ Max (individual) shares the same rate limits as team plans. Team plans (Pro or M
| **Max** | 500 GB |
| **Enterprise** | 500 GB (customizable) |
-Team plans (Pro or Max for Teams) use 500 GB.
+Storage follows the paid tier: Pro and Pro for Teams share the Pro limit, while Max and Max for Teams share the Max limit. Organization storage is pooled across the organization's workspaces.
### Run Time Limits
diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
index 64fde9c98cc..4e222b68dd3 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
@@ -121,6 +121,29 @@ Configure one provider — the mailer auto-detects in priority order: **Resend
|----------|-------------|
| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string |
+## Limits
+
+Self-hosted deployments (billing disabled) run without plan limits: no rate limits, no execution timeouts, no table or storage caps, and no retention-based data deletion. Each limit can be opted back in individually by explicitly setting its variable.
+
+| Variable | Opts in | Suggested value |
+|----------|---------|-----------------|
+| `RATE_LIMIT_FREE_SYNC` | Sync executions per minute | `50` |
+| `RATE_LIMIT_FREE_ASYNC` | Async executions per minute | `200` |
+| `RATE_LIMIT_FREE_API_ENDPOINT` | v1 API endpoint requests per minute | `30` |
+| `EXECUTION_TIMEOUT_FREE` | Sync execution timeout (seconds) | `300` |
+| `EXECUTION_TIMEOUT_ASYNC_FREE` | Async execution timeout (seconds) | `5400` |
+| `FREE_TABLES_LIMIT` | Max user tables per workspace | `5` |
+| `FREE_TABLE_ROWS_LIMIT` | Max rows per user table | `50000` |
+| `FREE_STORAGE_LIMIT_GB` | File storage quota (GB) | `5` |
+
+
+ Without billing, every account resolves to the free tier, so only the free-tier variables apply. Setting one variable enforces only that limit — the rest stay unlimited.
+
+
+
+ Deployments installed with the Helm chart ship these variables preset in `app.envDefaults`, so chart-based installs keep enforcement unless those keys are removed or overridden.
+
+
## Example .env
```bash
diff --git a/apps/sim/.env.example b/apps/sim/.env.example
index 9366575fd54..7e964524723 100644
--- a/apps/sim/.env.example
+++ b/apps/sim/.env.example
@@ -112,3 +112,15 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
+
+# Limits (Optional - self-hosted). With billing disabled (BILLING_ENABLED unset), no plan
+# limits are enforced. Explicitly setting a free-tier variable below opts that specific
+# limit back in at the configured value.
+# RATE_LIMIT_FREE_SYNC=50 # Sync executions per minute
+# RATE_LIMIT_FREE_ASYNC=200 # Async executions per minute
+# RATE_LIMIT_FREE_API_ENDPOINT=30 # v1 API endpoint requests per minute
+# EXECUTION_TIMEOUT_FREE=300 # Sync execution timeout in seconds
+# EXECUTION_TIMEOUT_ASYNC_FREE=5400 # Async execution timeout in seconds
+# FREE_TABLES_LIMIT=5 # Max user tables per workspace
+# FREE_TABLE_ROWS_LIMIT=50000 # Max rows per user table
+# FREE_STORAGE_LIMIT_GB=5 # File storage quota in GB
diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts
index f0261e111ef..a60c32443f6 100644
--- a/apps/sim/app/api/mcp/tools/execute/route.ts
+++ b/apps/sim/app/api/mcp/tools/execute/route.ts
@@ -213,15 +213,26 @@ export const POST = withRouteHandler(
}
let timeoutHandle: ReturnType | undefined
- const result = await Promise.race([
- mcpService.executeTool(userId, serverId, toolCall, workspaceId, extraHeaders),
- new Promise((_, reject) => {
- timeoutHandle = setTimeout(
- () => reject(new Error('Tool execution timeout')),
- executionTimeout
- )
- }),
- ]).finally(() => {
+ const executePromise = mcpService.executeTool(
+ userId,
+ serverId,
+ toolCall,
+ workspaceId,
+ extraHeaders
+ )
+ // A zero timeout means "no timeout" (billing-disabled deployments).
+ const result = await (executionTimeout > 0
+ ? Promise.race([
+ executePromise,
+ new Promise((_, reject) => {
+ timeoutHandle = setTimeout(
+ () => reject(new Error('Tool execution timeout')),
+ executionTimeout
+ )
+ }),
+ ])
+ : executePromise
+ ).finally(() => {
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
index 88ddfde1922..a07ca838a1b 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_BILLING_CONCURRENCY_LIMITS } from '@/lib/billing/concurrency-defaults'
import {
CREDIT_TIERS,
CREDITS_PER_DOLLAR,
@@ -116,6 +117,20 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
},
],
},
+ {
+ title: 'Execution concurrency',
+ rows: [
+ {
+ label: 'Concurrent executions',
+ values: [
+ DEFAULT_BILLING_CONCURRENCY_LIMITS.free.toLocaleString('en-US'),
+ DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US'),
+ DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US'),
+ `${DEFAULT_BILLING_CONCURRENCY_LIMITS.enterprise.toLocaleString('en-US')} (customizable)`,
+ ],
+ },
+ ],
+ },
{
title: 'Rate limits (runs/min)',
rows: [
diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
index 4a5a154b9a8..4a524b30fe0 100644
--- a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
+++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
@@ -1,3 +1,5 @@
+import { DEFAULT_BILLING_CONCURRENCY_LIMITS } from '@/lib/billing/concurrency-defaults'
+
/**
* Config for a plan's top-level credit stats.
* When `credits` is omitted the credits/refresh block is not rendered.
@@ -25,6 +27,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
}
export const PRO_PLAN_FEATURES: readonly string[] = [
+ `${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Deploy workflows as APIs',
'Extended run timeouts',
@@ -32,6 +35,7 @@ export const PRO_PLAN_FEATURES: readonly string[] = [
]
export const MAX_PLAN_FEATURES: readonly string[] = [
+ `${DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Sim Mailer & KB Live Sync',
'Highest rate limits',
@@ -39,6 +43,7 @@ export const MAX_PLAN_FEATURES: readonly string[] = [
]
export const ENTERPRISE_PLAN_FEATURES: readonly string[] = [
+ `${DEFAULT_BILLING_CONCURRENCY_LIMITS.enterprise.toLocaleString('en-US')} concurrent executions, customizable`,
'Custom limits & infrastructure',
'SSO & SOC2 compliance',
'Access control & self-hosting',
diff --git a/apps/sim/lib/admin/dashboard.ts b/apps/sim/lib/admin/dashboard.ts
index b4519d82e03..9fccdec4892 100644
--- a/apps/sim/lib/admin/dashboard.ts
+++ b/apps/sim/lib/admin/dashboard.ts
@@ -29,6 +29,8 @@ import {
getOrganizationUsageLimitFallbackDollars,
getTeamOrganizationEconomics,
} from '@/lib/admin/organization-economics'
+import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
+import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
@@ -190,6 +192,13 @@ function buildDashboardOrganizationSummary({
latestSubscription?.plan === 'enterprise'
? Math.max(0, Math.round(metadataNumber(metadata, 'seats') ?? 0))
: memberCount
+ const concurrencyLimit =
+ latestSubscription?.plan === 'enterprise'
+ ? getBillingConcurrencyLimit(
+ latestSubscription.plan,
+ parseBillingConcurrencyLimit(metadata.concurrencyLimit)
+ )
+ : null
return {
id: org.id,
@@ -202,6 +211,7 @@ function buildDashboardOrganizationSummary({
memberCount,
externalCollaboratorCount,
seats,
+ concurrencyLimit,
includedMonthlyDollars,
usageLimitDollars,
effectiveUsageLimitDollars,
@@ -547,7 +557,11 @@ export async function updateDashboardEnterpriseSeats(
export async function updateDashboardOrganizationLimits(
organizationId: string,
- values: { includedMonthlyDollars?: number; usageLimitDollars?: number },
+ values: {
+ includedMonthlyDollars?: number
+ usageLimitDollars?: number
+ concurrencyLimit?: number | null
+ },
actor: AdminMutationActor
) {
await db.transaction(async (tx) => {
@@ -574,6 +588,9 @@ export async function updateDashboardOrganizationLimits(
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')
+ }
if (subscriptionRow?.plan === 'enterprise') {
if (!hasPaidSubscriptionStatus(subscriptionRow.status)) {
@@ -598,6 +615,9 @@ export async function updateDashboardOrganizationLimits(
...current,
includedMonthlyCredits: included,
usageLimitCredits: configuredUsageLimit,
+ ...(values.concurrencyLimit !== undefined
+ ? { concurrencyLimit: values.concurrencyLimit }
+ : {}),
}
},
})
@@ -650,7 +670,7 @@ export async function updateDashboardOrganizationLimits(
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
- description: 'Admin updated organization credit limits',
+ description: 'Admin updated organization limits',
metadata: values,
})
}
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 78da61caa4d..952fc48b3f3 100644
--- a/apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts
+++ b/apps/sim/lib/api/contracts/v1/admin/dashboard.test.ts
@@ -3,6 +3,8 @@
import { describe, expect, it } from 'vitest'
import {
adminDashboardBalanceGrantBodySchema,
+ adminDashboardIssueEnterpriseBodySchema,
+ adminDashboardLimitsBodySchema,
adminDashboardOrganizationSummarySchema,
adminDashboardUpdateMemberBodySchema,
} from '@/lib/api/contracts/v1/admin/dashboard'
@@ -55,6 +57,7 @@ describe('admin dashboard credit grant contract', () => {
memberCount: 0,
externalCollaboratorCount: 0,
seats: 0,
+ concurrencyLimit: null,
includedMonthlyDollars: 0,
usageLimitDollars: 0.001,
effectiveUsageLimitDollars: 0.001,
@@ -64,4 +67,33 @@ describe('admin dashboard credit grant contract', () => {
}).success
).toBe(true)
})
+
+ it('accepts positive integer Enterprise concurrency limits', () => {
+ expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 1250 }).success).toBe(true)
+ expect(
+ adminDashboardIssueEnterpriseBodySchema.safeParse({
+ ownerUserId: 'owner-1',
+ monthlyInvoiceAmountUsd: 500,
+ includedMonthlyDollars: 500,
+ seats: 10,
+ concurrencyLimit: 1250,
+ pausePaymentCollection: true,
+ }).success
+ ).toBe(true)
+ expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 0 }).success).toBe(false)
+ expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: 1.5 }).success).toBe(false)
+ })
+
+ it('accepts null to restore the deployment-wide Enterprise concurrency default', () => {
+ expect(adminDashboardLimitsBodySchema.safeParse({ concurrencyLimit: null }).success).toBe(true)
+ expect(
+ adminDashboardIssueEnterpriseBodySchema.safeParse({
+ ownerUserId: 'owner-1',
+ monthlyInvoiceAmountUsd: 500,
+ includedMonthlyDollars: 500,
+ seats: 10,
+ concurrencyLimit: null,
+ }).success
+ ).toBe(false)
+ })
})
diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts
index 19686810c2a..2e106c5de6e 100644
--- a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts
+++ b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts
@@ -7,6 +7,7 @@ import {
adminV1QueryStringSchema,
adminV1SingleResponseSchema,
} from '@/lib/api/contracts/v1/admin/shared'
+import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'
const dollarAmountSchema = z
.number()
@@ -40,6 +41,8 @@ export const adminDashboardProvisioningSchema = z.object({
includedMonthlyDollars: creditAlignedDollarAmountSchema,
usageLimitDollars: creditAlignedDollarAmountSchema,
seats: z.number().int().positive(),
+ concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT),
+ pausePaymentCollection: z.boolean(),
stripeSubscriptionId: z.string().nullable(),
error: z.string().nullable(),
createdAt: z.string(),
@@ -57,6 +60,7 @@ export const adminDashboardOrganizationSummarySchema = z.object({
memberCount: z.number().int().min(0),
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,
usageLimitDollars: dollarAmountSchema,
effectiveUsageLimitDollars: dollarAmountSchema,
@@ -111,6 +115,8 @@ export const adminDashboardIssueEnterpriseBodySchema = z.object({
includedMonthlyDollars: creditAlignedDollarAmountSchema,
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
seats: z.number().int().positive().max(100_000),
+ concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).optional(),
+ pausePaymentCollection: z.boolean().optional(),
})
export const adminDashboardSeatsBodySchema = z.object({
@@ -121,9 +127,19 @@ export const adminDashboardLimitsBodySchema = z
.object({
includedMonthlyDollars: creditAlignedDollarAmountSchema.optional(),
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
+ concurrencyLimit: z
+ .number()
+ .int()
+ .positive()
+ .max(MAX_BILLING_CONCURRENCY_LIMIT)
+ .nullable()
+ .optional(),
})
.refine(
- (value) => value.includedMonthlyDollars !== undefined || value.usageLimitDollars !== undefined,
+ (value) =>
+ value.includedMonthlyDollars !== undefined ||
+ value.usageLimitDollars !== undefined ||
+ value.concurrencyLimit !== undefined,
{ error: 'At least one limit must be provided' }
)
diff --git a/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts b/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts
new file mode 100644
index 00000000000..9a998355bf7
--- /dev/null
+++ b/apps/sim/lib/billing/calculations/usage-reservation-env.test.ts
@@ -0,0 +1,71 @@
+/**
+ * @vitest-environment node
+ */
+import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { evalMock, mockEnv } = vi.hoisted(() => ({
+ evalMock: vi.fn(),
+ mockEnv: {
+ BILLING_CONCURRENCY_LIMIT_FREE: '11',
+ BILLING_CONCURRENCY_LIMIT_PRO: '55',
+ BILLING_CONCURRENCY_LIMIT_TEAM: '222',
+ BILLING_CONCURRENCY_LIMIT_ENTERPRISE: '1111',
+ },
+}))
+
+vi.mock('@/lib/core/config/env', () => ({
+ env: mockEnv,
+ envNumber: (
+ value: number | string | undefined | null,
+ fallback: number,
+ options: { min?: number; integer?: boolean } = {}
+ ) => {
+ const parsed = Number(value)
+ const min = options.min ?? 0
+ return Number.isFinite(parsed) &&
+ parsed >= min &&
+ (!options.integer || Number.isInteger(parsed))
+ ? parsed
+ : fallback
+ },
+}))
+
+vi.mock('@/lib/core/config/env-flags', () => ({
+ isBillingEnabled: true,
+ isHosted: true,
+}))
+
+vi.mock('@/lib/core/config/redis', () => redisConfigMock)
+
+import { reserveExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
+
+describe('usage reservation environment overrides', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ evalMock.mockResolvedValue(1)
+ redisConfigMockFns.mockGetRedisClient.mockReturnValue({
+ eval: evalMock,
+ get: vi.fn(),
+ })
+ })
+
+ it.each([
+ ['free', '11'],
+ ['pro_6000', '55'],
+ ['team_6000', '55'],
+ ['pro_25000', '222'],
+ ['team_25000', '222'],
+ ['enterprise', '1111'],
+ ] as const)('uses the %s plan override', async (plan, expected) => {
+ await reserveExecutionSlot({
+ billingEntity: { type: 'user', id: 'user-1' },
+ reservationId: `exec-${plan}`,
+ plan,
+ currentUsage: 0,
+ limit: 100,
+ })
+
+ expect(evalMock.mock.calls[0][6]).toBe(expected)
+ })
+})
diff --git a/apps/sim/lib/billing/calculations/usage-reservation.test.ts b/apps/sim/lib/billing/calculations/usage-reservation.test.ts
index b7317990650..ea8d6332aec 100644
--- a/apps/sim/lib/billing/calculations/usage-reservation.test.ts
+++ b/apps/sim/lib/billing/calculations/usage-reservation.test.ts
@@ -41,7 +41,7 @@ const baseParams = {
const memberParams = {
...baseParams,
billingEntity: { type: 'organization' as const, id: 'org-1' },
- plan: 'team' as const,
+ plan: 'team_25000' as const,
member: {
organizationId: 'org-1',
actorUserId: 'user-1',
@@ -138,7 +138,7 @@ describe('usage-reservation', () => {
expect(args[1]).toBe(2)
expect(args[2]).toContain('{user:user-1}')
expect(args[3]).toContain('{user:user-1}')
- expect(args[6]).toBe('15')
+ expect(args[6]).toBe('10')
expect(args[7]).toBe('1000')
expect(args[8]).toBe('exec-1')
})
@@ -150,22 +150,47 @@ describe('usage-reservation', () => {
expect(args[1]).toBe(3)
const declaredKeys = args.slice(2, 5) as string[]
expect(new Set(declaredKeys.map(hashTag))).toEqual(new Set(['org:org-1']))
- expect(args[7]).toBe('150')
+ expect(args[7]).toBe('200')
expect(args.at(-1)).toBe('1')
expect(args[0]).not.toContain('usage:')
})
- it('caps enterprise payer work at 300 entries without Lua enumeration', async () => {
+ it('caps enterprise payer work at 1,000 entries without Lua enumeration', async () => {
evalMock.mockResolvedValueOnce(1).mockResolvedValueOnce(1)
await reserveExecutionSlot({ ...baseParams, plan: 'enterprise' })
const args = evalMock.mock.calls[0]
- expect(args[6]).toBe('300')
+ expect(args[6]).toBe('1000')
expect(args[0]).toContain("redis.call('ZREMRANGEBYSCORE'")
expect(args[0]).not.toMatch(/ZRANGE|SMEMBERS|HGETALL|KEYS\s/)
})
+ it.each([
+ ['pro_6000', '50'],
+ ['team_6000', '50'],
+ ['pro_25000', '200'],
+ ['team_25000', '200'],
+ ] as const)('gives %s its paid tier concurrency cap of %s', async (plan, expected) => {
+ evalMock.mockResolvedValueOnce(1).mockResolvedValueOnce(1)
+
+ await reserveExecutionSlot({ ...baseParams, plan })
+
+ expect(evalMock.mock.calls[0][6]).toBe(expected)
+ })
+
+ it('uses an Enterprise subscription metadata concurrency override', async () => {
+ evalMock.mockResolvedValueOnce(1).mockResolvedValueOnce(1)
+
+ await reserveExecutionSlot({
+ ...baseParams,
+ plan: 'enterprise',
+ enterpriseConcurrencyLimit: 1250,
+ })
+
+ expect(evalMock.mock.calls[0][6]).toBe('1250')
+ })
+
it('clamps negative headroom to zero slots', async () => {
evalMock.mockResolvedValueOnce(3)
await reserveExecutionSlot({ ...baseParams, currentUsage: 10, limit: 5 })
diff --git a/apps/sim/lib/billing/calculations/usage-reservation.ts b/apps/sim/lib/billing/calculations/usage-reservation.ts
index 5e0445628cd..dd1da572fa0 100644
--- a/apps/sim/lib/billing/calculations/usage-reservation.ts
+++ b/apps/sim/lib/billing/calculations/usage-reservation.ts
@@ -1,9 +1,9 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
+import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import type { BillingEntity } from '@/lib/billing/core/usage-log'
-import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
import {
ADMISSION_ERROR_DESCRIPTOR,
type ReservationDenialReason,
@@ -11,7 +11,6 @@ import {
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { getRedisClient } from '@/lib/core/config/redis'
import { getExecutionReservationTtlMs } from '@/lib/core/execution-limits'
-import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
const logger = createLogger('UsageReservation')
@@ -27,13 +26,6 @@ const logger = createLogger('UsageReservation')
* executions per billing entity bounds the worst-case overshoot to roughly this
* many executions' worth of spend.
*/
-const MAX_CONCURRENT_EXECUTIONS: Record = {
- free: 15,
- pro: 75,
- team: 150,
- enterprise: 300,
-}
-
/**
* The guaranteed $0.005 base charge reserved per active execution. This is not
* an estimate of final model or tool spend; it only closes the concurrent race
@@ -42,14 +34,12 @@ const MAX_CONCURRENT_EXECUTIONS: Record = {
const RESERVED_BASE_EXECUTION_CHARGE = BASE_EXECUTION_CHARGE
/**
- * Identifiers are bounded before becoming Redis keys or pointer data. With the
- * 300-entry enterprise ceiling, one payer can hold at most 300 owner keys, 300
- * pointer keys, 300 member keys, one payer key, and 600 total sorted-set
- * entries. Each generated key is below 450 bytes and each pointer below 512
- * bytes: at most 901 keys and under 626 KiB of application-controlled
- * key/member/value/score bytes per payer, excluding bounded Redis object
- * overhead. Absolute TTLs bound crash remnants; pause paths explicitly release
- * them.
+ * Identifiers are bounded before becoming Redis keys or pointer data. The
+ * configured payer ceiling bounds owner, pointer, member, and sorted-set
+ * cardinality; the hosted Enterprise default is 1,000 and a subscription
+ * metadata override can intentionally replace it. Each generated key is below
+ * 450 bytes and each pointer below 512 bytes. Absolute TTLs bound crash
+ * remnants; pause paths explicitly release them.
*/
const MAX_IDENTIFIER_LENGTH = 128
const MAX_POINTER_BYTES = 512
@@ -70,10 +60,10 @@ const RESERVE_DUPLICATE = 5
* share the payer hash tag, so Redis Cluster executes the script in one slot.
*
* The script performs only constant-size scalar work plus sorted-set operations
- * over at most `maxConcurrency` entries (300). No Lua collection grows with
- * traffic. Base-charge slots have no floor: zero remaining guaranteed-minimum
- * charges is a headroom rejection, while duplicate reservation ids remain
- * idempotent.
+ * over at most the configured `maxConcurrency` entries. No Lua collection
+ * grows with traffic. Base-charge slots have no floor: zero remaining
+ * guaranteed-minimum charges is a headroom rejection, while duplicate
+ * reservation ids remain idempotent.
*/
const RESERVE_SCRIPT = `
local now = tonumber(ARGV[1])
@@ -230,10 +220,6 @@ export function resolveBillingEntityKey(billingEntity: BillingEntity): string {
return `${billingEntity.type === 'organization' ? 'org' : 'user'}:${id}`
}
-function getMaxConcurrentExecutions(plan: string | null | undefined): number {
- return MAX_CONCURRENT_EXECUTIONS[getPlanTypeForLimits(plan) as SubscriptionPlan]
-}
-
function requireBoundedIdentifier(value: string, label: string): string {
if (
typeof value !== 'string' ||
@@ -373,6 +359,8 @@ async function rollbackCreatedReservation(params: {
interface ReserveExecutionSlotBaseParams {
billingEntity: BillingEntity
plan: string | null | undefined
+ /** Optional positive Enterprise subscription metadata override. */
+ enterpriseConcurrencyLimit?: number | null
/** Recorded usage for the billing entity at admission time (dollars). */
currentUsage: number
/** The entity's usage cap (dollars). */
@@ -443,7 +431,7 @@ export async function reserveExecutionSlot(
const keys = buildLocalKeys(descriptor, reservationId)
const keyArgs = localKeyArguments(keys)
const pointerKey = `${POINTER_KEY_PREFIX}${reservationId}`
- const maxConcurrency = getMaxConcurrentExecutions(plan)
+ const maxConcurrency = getBillingConcurrencyLimit(plan, params.enterpriseConcurrencyLimit)
const currentUsage = requireUsageNumber(params.currentUsage, 'payer current usage')
const limit = requireUsageNumber(params.limit, 'payer limit')
const payerHeadroom = Math.max(0, limit - currentUsage)
diff --git a/apps/sim/lib/billing/cleanup-dispatcher.test.ts b/apps/sim/lib/billing/cleanup-dispatcher.test.ts
new file mode 100644
index 00000000000..e696d221895
--- /dev/null
+++ b/apps/sim/lib/billing/cleanup-dispatcher.test.ts
@@ -0,0 +1,49 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockFlags, mockIsTriggerAvailable, mockSelect } = vi.hoisted(() => ({
+ mockFlags: { isBillingEnabled: false },
+ mockIsTriggerAvailable: vi.fn(),
+ mockSelect: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => ({ db: { select: mockSelect } }))
+vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: vi.fn() }))
+vi.mock('@/lib/billing/core/subscription', () => ({
+ getHighestPriorityPersonalSubscription: vi.fn(),
+}))
+vi.mock('@/lib/cleanup/batch-delete', () => ({ chunkArray: vi.fn() }))
+vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn() }))
+vi.mock('@/lib/core/async-jobs/config', () => ({ shouldExecuteInline: vi.fn(() => false) }))
+vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn() }))
+vi.mock('@/lib/core/config/env-flags', () => ({
+ get isBillingEnabled() {
+ return mockFlags.isBillingEnabled
+ },
+}))
+vi.mock('@/lib/knowledge/documents/service', () => ({
+ isTriggerAvailable: mockIsTriggerAvailable,
+}))
+vi.mock('@/lib/workspaces/policy', () => ({
+ WORKSPACE_MODE: { PERSONAL: 'personal', ORGANIZATION: 'organization' },
+ isOrganizationWorkspace: vi.fn(),
+}))
+
+import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
+
+describe('dispatchCleanupJobs billing gate', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFlags.isBillingEnabled = false
+ })
+
+ it('never dispatches plan-based retention deletion when billing is disabled', async () => {
+ const result = await dispatchCleanupJobs('cleanup-logs')
+
+ expect(result).toEqual({ jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 })
+ expect(mockIsTriggerAvailable).not.toHaveBeenCalled()
+ expect(mockSelect).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts
index 38b4a18f114..78a97e4dd72 100644
--- a/apps/sim/lib/billing/cleanup-dispatcher.ts
+++ b/apps/sim/lib/billing/cleanup-dispatcher.ts
@@ -13,6 +13,7 @@ import { getJobQueue } from '@/lib/core/async-jobs'
import { shouldExecuteInline } from '@/lib/core/async-jobs/config'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import type { EnqueueOptions } from '@/lib/core/async-jobs/types'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { isTriggerAvailable } from '@/lib/knowledge/documents/service'
import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy'
@@ -300,6 +301,15 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{
chunkCount: number
workspaceCount: number
}> {
+ /**
+ * Plan-based retention is a hosted billing policy. Billing-disabled
+ * deployments must never delete user data on the hosted defaults.
+ */
+ if (!isBillingEnabled) {
+ logger.info(`[${jobType}] Skipping cleanup dispatch: billing is disabled`)
+ return { jobIds: [], jobCount: 0, chunkCount: 0, workspaceCount: 0 }
+ }
+
const jobIds: string[] = []
let succeeded = 0
let failed = 0
diff --git a/apps/sim/lib/billing/concurrency-defaults.test.ts b/apps/sim/lib/billing/concurrency-defaults.test.ts
new file mode 100644
index 00000000000..b3ab8e21fd9
--- /dev/null
+++ b/apps/sim/lib/billing/concurrency-defaults.test.ts
@@ -0,0 +1,27 @@
+/** @vitest-environment node */
+
+import { describe, expect, it } from 'vitest'
+import {
+ DEFAULT_BILLING_CONCURRENCY_LIMITS,
+ MAX_BILLING_CONCURRENCY_LIMIT,
+ parseBillingConcurrencyLimit,
+} from '@/lib/billing/concurrency-defaults'
+
+describe('billing concurrency defaults', () => {
+ it('keeps the hosted plan progression explicit', () => {
+ expect(DEFAULT_BILLING_CONCURRENCY_LIMITS).toEqual({
+ free: 10,
+ pro: 50,
+ team: 200,
+ enterprise: 1000,
+ })
+ })
+
+ it('normalizes safe metadata and environment override values', () => {
+ expect(parseBillingConcurrencyLimit('1250')).toBe(1250)
+ expect(parseBillingConcurrencyLimit(1250)).toBe(1250)
+ expect(parseBillingConcurrencyLimit(0)).toBeNull()
+ expect(parseBillingConcurrencyLimit(1.5)).toBeNull()
+ expect(parseBillingConcurrencyLimit(MAX_BILLING_CONCURRENCY_LIMIT + 1)).toBeNull()
+ })
+})
diff --git a/apps/sim/lib/billing/concurrency-defaults.ts b/apps/sim/lib/billing/concurrency-defaults.ts
new file mode 100644
index 00000000000..35a21c7c318
--- /dev/null
+++ b/apps/sim/lib/billing/concurrency-defaults.ts
@@ -0,0 +1,31 @@
+import type { PlanCategory } from '@/lib/billing/plan-helpers'
+
+/**
+ * Hosted product defaults for in-flight workflow executions per billing
+ * account. Buckets follow the paid tier: `pro` covers Pro and Pro for Teams,
+ * and `team` is the Max tier covering Max and Max for Teams.
+ */
+export const DEFAULT_BILLING_CONCURRENCY_LIMITS = {
+ free: 10,
+ pro: 50,
+ team: 200,
+ enterprise: 1000,
+} as const satisfies Record
+
+/** Safety ceiling for operator and Enterprise subscription overrides. */
+export const MAX_BILLING_CONCURRENCY_LIMIT = 10_000
+
+/**
+ * Parses a positive execution-concurrency limit within the Redis safety bound.
+ */
+export function parseBillingConcurrencyLimit(value: unknown): number | null {
+ const parsed =
+ typeof value === 'number'
+ ? value
+ : typeof value === 'string' && value.trim().length > 0
+ ? Number(value)
+ : Number.NaN
+ return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_BILLING_CONCURRENCY_LIMIT
+ ? parsed
+ : null
+}
diff --git a/apps/sim/lib/billing/concurrency-limits.ts b/apps/sim/lib/billing/concurrency-limits.ts
new file mode 100644
index 00000000000..4ccb0b62778
--- /dev/null
+++ b/apps/sim/lib/billing/concurrency-limits.ts
@@ -0,0 +1,51 @@
+import {
+ DEFAULT_BILLING_CONCURRENCY_LIMITS,
+ parseBillingConcurrencyLimit,
+} from '@/lib/billing/concurrency-defaults'
+import { getPlanTypeForLimits, type PlanCategory } from '@/lib/billing/plan-helpers'
+import { env, envNumber } from '@/lib/core/config/env'
+
+function configuredConcurrencyLimit(value: number | string | undefined, fallback: number): number {
+ const parsed = envNumber(value, fallback, { min: 1, integer: true })
+ return parseBillingConcurrencyLimit(parsed) ?? fallback
+}
+
+/**
+ * Resolves operator-configured defaults for every plan limit category.
+ */
+export function getBillingConcurrencyLimits(): Record {
+ return {
+ free: configuredConcurrencyLimit(
+ env.BILLING_CONCURRENCY_LIMIT_FREE,
+ DEFAULT_BILLING_CONCURRENCY_LIMITS.free
+ ),
+ pro: configuredConcurrencyLimit(
+ env.BILLING_CONCURRENCY_LIMIT_PRO,
+ DEFAULT_BILLING_CONCURRENCY_LIMITS.pro
+ ),
+ team: configuredConcurrencyLimit(
+ env.BILLING_CONCURRENCY_LIMIT_TEAM,
+ DEFAULT_BILLING_CONCURRENCY_LIMITS.team
+ ),
+ enterprise: configuredConcurrencyLimit(
+ env.BILLING_CONCURRENCY_LIMIT_ENTERPRISE,
+ DEFAULT_BILLING_CONCURRENCY_LIMITS.enterprise
+ ),
+ }
+}
+
+/**
+ * Resolves one payer's concurrency ceiling. A valid Enterprise subscription
+ * metadata override takes precedence over the deployment-wide default.
+ */
+export function getBillingConcurrencyLimit(
+ plan: string | null | undefined,
+ enterpriseConcurrencyLimit?: number | null
+): number {
+ const planType = getPlanTypeForLimits(plan)
+ if (planType === 'enterprise') {
+ const customLimit = parseBillingConcurrencyLimit(enterpriseConcurrencyLimit)
+ if (customLimit !== null) return customLimit
+ }
+ return getBillingConcurrencyLimits()[planType]
+}
diff --git a/apps/sim/lib/billing/core/billing-attribution.test.ts b/apps/sim/lib/billing/core/billing-attribution.test.ts
index f16f1d0fbb4..fb6105a6bc8 100644
--- a/apps/sim/lib/billing/core/billing-attribution.test.ts
+++ b/apps/sim/lib/billing/core/billing-attribution.test.ts
@@ -275,6 +275,31 @@ describe('resolveBillingAttribution', () => {
expect(JSON.stringify(attribution)).not.toContain('stripe-subscription')
})
+ it('carries only the normalized Enterprise concurrency metadata needed by admission', async () => {
+ mockLimit.mockResolvedValue([
+ {
+ billedAccountUserId: 'owner-b',
+ organizationId: 'org-b',
+ },
+ ])
+ mockGetOrganizationSubscription.mockResolvedValue({
+ ...ORG_SUBSCRIPTION,
+ plan: 'enterprise',
+ metadata: { concurrencyLimit: '1250', secret: 'must-not-cross-boundary' },
+ })
+
+ const attribution = await resolveBillingAttribution({
+ actorUserId: 'actor-a',
+ workspaceId: 'workspace-b',
+ })
+
+ expect(attribution.payerSubscription).toMatchObject({
+ plan: 'enterprise',
+ enterpriseConcurrencyLimit: 1250,
+ })
+ expect(JSON.stringify(attribution)).not.toContain('must-not-cross-boundary')
+ })
+
it('rejects a subscription that does not belong to the exact workspace payer', async () => {
mockLimit.mockResolvedValue([
{
diff --git a/apps/sim/lib/billing/core/billing-attribution.ts b/apps/sim/lib/billing/core/billing-attribution.ts
index acc89e3a3d8..111fb7007a9 100644
--- a/apps/sim/lib/billing/core/billing-attribution.ts
+++ b/apps/sim/lib/billing/core/billing-attribution.ts
@@ -9,10 +9,12 @@ import {
checkOrganizationMemberUsageLimit,
checkUsageStatus,
} from '@/lib/billing/calculations/usage-monitor'
+import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
import type { BillingContext, BillingEntity } from '@/lib/billing/core/usage-log'
+import { isEnterprise } from '@/lib/billing/plan-helpers'
import {
BILLING_ACCOUNT_DECISION_HEADER,
BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES,
@@ -43,6 +45,7 @@ export interface PayerSubscriptionSnapshot {
readonly seats: number | null
readonly periodStart: string | null
readonly periodEnd: string | null
+ readonly enterpriseConcurrencyLimit?: number
}
export interface BillingPeriodSnapshot {
@@ -121,6 +124,10 @@ function serializeSubscription(
): PayerSubscriptionSnapshot | null {
if (!subscription) return null
+ const enterpriseConcurrencyLimit =
+ isEnterprise(subscription.plan) && isRecordLike(subscription.metadata)
+ ? parseBillingConcurrencyLimit(subscription.metadata.concurrencyLimit)
+ : null
return Object.freeze({
id: subscription.id,
referenceId: subscription.referenceId,
@@ -129,6 +136,7 @@ function serializeSubscription(
seats: subscription.seats ?? null,
periodStart: subscription.periodStart?.toISOString() ?? null,
periodEnd: subscription.periodEnd?.toISOString() ?? null,
+ ...(enterpriseConcurrencyLimit !== null ? { enterpriseConcurrencyLimit } : {}),
})
}
@@ -242,6 +250,15 @@ export function assertBillingAttributionSnapshot(value: unknown): BillingAttribu
if (subscription.referenceId !== rawEntity.id) {
throw new Error('Billing attribution subscription does not belong to its billing entity')
}
+ const enterpriseConcurrencyLimit = subscription.enterpriseConcurrencyLimit
+ if (
+ enterpriseConcurrencyLimit !== undefined &&
+ (!isEnterprise(subscription.plan) ||
+ typeof enterpriseConcurrencyLimit !== 'number' ||
+ parseBillingConcurrencyLimit(enterpriseConcurrencyLimit) !== enterpriseConcurrencyLimit)
+ ) {
+ throw new Error('Billing attribution Enterprise concurrency limit is invalid')
+ }
payerSubscription = {
id: subscription.id,
@@ -251,6 +268,7 @@ export function assertBillingAttributionSnapshot(value: unknown): BillingAttribu
seats: subscription.seats as number | null,
periodStart: subscriptionStart?.toISOString() ?? null,
periodEnd: subscriptionEnd?.toISOString() ?? null,
+ ...(enterpriseConcurrencyLimit !== undefined ? { enterpriseConcurrencyLimit } : {}),
}
}
diff --git a/apps/sim/lib/billing/enterprise-outbox.test.ts b/apps/sim/lib/billing/enterprise-outbox.test.ts
index 58e0684ba3a..22c68e46b16 100644
--- a/apps/sim/lib/billing/enterprise-outbox.test.ts
+++ b/apps/sim/lib/billing/enterprise-outbox.test.ts
@@ -35,7 +35,7 @@ import {
const payload = {
version: 1 as const,
request: {
- requestKey: 'enterprise-v2:owner-1:org-1:10000:20000:20000:5',
+ requestKey: 'enterprise-v2:owner-1:org-1:10000:20000:20000:5:1250',
ownerUserId: 'owner-1',
organizationId: 'org-1',
requestedByEmail: 'admin@sim.ai',
@@ -44,6 +44,8 @@ const payload = {
includedMonthlyCredits: 20000,
usageLimitCredits: 20000,
seats: 5,
+ concurrencyLimit: 1250,
+ pausePaymentCollection: false,
},
retryRevision: 0,
stripeProgress: {},
@@ -60,6 +62,7 @@ function stripeSubscription(
collectionMethod?: string
daysUntilDue?: number
metadata?: Record
+ pauseCollection?: { behavior: 'keep_as_draft'; resumes_at: number | null } | null
} = {}
): Stripe.Subscription {
const price = {
@@ -74,12 +77,14 @@ function stripeSubscription(
return {
collection_method: options.collectionMethod ?? 'send_invoice',
days_until_due: options.daysUntilDue ?? 30,
+ pause_collection: options.pauseCollection ?? null,
items: { data: Array.from({ length: options.itemCount ?? 1 }, () => item) },
metadata: {
invoiceAmountCents: '10000',
includedMonthlyCredits: '20000',
usageLimitCredits: '20000',
seats: '5',
+ concurrencyLimit: '1250',
...options.metadata,
},
} as unknown as Stripe.Subscription
@@ -119,6 +124,20 @@ describe('Enterprise outbox operation state', () => {
}).success
).toBe(false)
})
+
+ it('keeps pre-concurrency Enterprise outbox payloads valid', () => {
+ const {
+ concurrencyLimit: _concurrencyLimit,
+ pausePaymentCollection: _pausePaymentCollection,
+ ...legacyRequest
+ } = payload.request
+ const parsed = enterpriseProvisionPayloadSchema.safeParse({
+ ...payload,
+ request: legacyRequest,
+ })
+ expect(parsed.success).toBe(true)
+ if (parsed.success) expect(parsed.data.request.pausePaymentCollection).toBe(false)
+ })
})
describe('Enterprise issuance Stripe-term correlation', () => {
@@ -128,6 +147,30 @@ describe('Enterprise issuance Stripe-term correlation', () => {
).toBe(true)
})
+ it('accepts an indefinitely draft-paused subscription only when requested', () => {
+ const pausedPayload = {
+ ...payload,
+ request: {
+ ...payload.request,
+ requestKey: 'enterprise-v2:owner-1:org-1:10000:20000:20000:5:1250:draft-collection',
+ pausePaymentCollection: true,
+ },
+ }
+ const pausedSubscription = stripeSubscription({
+ pauseCollection: { behavior: 'keep_as_draft', resumes_at: null },
+ })
+
+ expect(
+ enterpriseOperationMatchesStripeSubscription(pausedPayload, pausedSubscription, 'org-1')
+ ).toBe(true)
+ expect(
+ enterpriseOperationMatchesStripeSubscription(pausedPayload, stripeSubscription(), 'org-1')
+ ).toBe(false)
+ expect(enterpriseOperationMatchesStripeSubscription(payload, pausedSubscription, 'org-1')).toBe(
+ false
+ )
+ })
+
it.each([
['two items', { itemCount: 2 }],
['quantity two', { quantity: 2 }],
@@ -139,6 +182,7 @@ describe('Enterprise issuance Stripe-term correlation', () => {
['wrong due terms', { daysUntilDue: 14 }],
['wrong credits', { metadata: { includedMonthlyCredits: '999' } }],
['wrong seats', { metadata: { seats: '6' } }],
+ ['wrong concurrency', { metadata: { concurrencyLimit: '999' } }],
] as const)('rejects %s', (_name, options) => {
expect(
enterpriseOperationMatchesStripeSubscription(payload, stripeSubscription(options), 'org-1')
diff --git a/apps/sim/lib/billing/enterprise-outbox.ts b/apps/sim/lib/billing/enterprise-outbox.ts
index 29c9973bbc1..39362028335 100644
--- a/apps/sim/lib/billing/enterprise-outbox.ts
+++ b/apps/sim/lib/billing/enterprise-outbox.ts
@@ -2,6 +2,7 @@ import { outboxEvent } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import type Stripe from 'stripe'
import { z } from 'zod'
+import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'
import type { DbOrTx } from '@/lib/db/types'
export const ENTERPRISE_PROVISION_EVENT_TYPE = 'stripe.provision-enterprise'
@@ -19,6 +20,8 @@ export const enterpriseProvisionRequestSchema = z.object({
includedMonthlyCredits: nonnegativeInteger,
usageLimitCredits: nonnegativeInteger,
seats: z.number().int().positive(),
+ concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).optional(),
+ pausePaymentCollection: z.boolean().default(false),
})
export const enterpriseProvisionPayloadSchema = z.object({
@@ -108,6 +111,10 @@ export function enterpriseOperationMatchesStripeSubscription(
const item = items[0]
const price = item?.price
const metadata = stripeSubscription.metadata ?? {}
+ const pauseCollection = stripeSubscription.pause_collection
+ const paymentCollectionMatches = request.pausePaymentCollection
+ ? pauseCollection?.behavior === 'keep_as_draft' && pauseCollection.resumes_at === null
+ : pauseCollection == null
return (
request.organizationId === referenceId &&
items.length === 1 &&
@@ -121,7 +128,10 @@ export function enterpriseOperationMatchesStripeSubscription(
stripeMetadataInteger(metadata, 'invoiceAmountCents') === request.invoiceAmountCents &&
stripeMetadataInteger(metadata, 'includedMonthlyCredits') === request.includedMonthlyCredits &&
stripeMetadataInteger(metadata, 'usageLimitCredits') === request.usageLimitCredits &&
- stripeMetadataInteger(metadata, 'seats') === request.seats
+ stripeMetadataInteger(metadata, 'seats') === request.seats &&
+ (request.concurrencyLimit === undefined ||
+ stripeMetadataInteger(metadata, 'concurrencyLimit') === request.concurrencyLimit) &&
+ paymentCollectionMatches
)
}
diff --git a/apps/sim/lib/billing/enterprise-provisioning.test.ts b/apps/sim/lib/billing/enterprise-provisioning.test.ts
index b8adfdfdc50..0af8df982f3 100644
--- a/apps/sim/lib/billing/enterprise-provisioning.test.ts
+++ b/apps/sim/lib/billing/enterprise-provisioning.test.ts
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
subscriptionsCreate: vi.fn(),
subscriptionsList: vi.fn(),
subscriptionsUpdate: vi.fn(),
+ invoicesRetrieve: vi.fn(),
+ invoicesUpdate: vi.fn(),
customersCreate: vi.fn(),
customersList: vi.fn(),
productsCreate: vi.fn(),
@@ -84,6 +86,7 @@ vi.mock('@/lib/billing/stripe-client', () => ({
list: mocks.subscriptionsList,
update: mocks.subscriptionsUpdate,
},
+ invoices: { retrieve: mocks.invoicesRetrieve, update: mocks.invoicesUpdate },
}),
}))
vi.mock('@/lib/billing/webhooks/enterprise-reconciliation-lease', () => ({
@@ -97,6 +100,7 @@ vi.mock('@/lib/core/outbox/service', () => ({
}))
import {
+ buildEnterpriseProvisioningRequestKey,
decideEnterpriseProvisioningIssue,
decideEnterpriseProvisioningRetry,
provisionEnterpriseInStripe,
@@ -128,7 +132,7 @@ function operationPayload(overrides: Record = {}) {
return {
version: 1 as const,
request: {
- requestKey: 'enterprise-v2:owner-1:org-1:12500:20000:24000:12',
+ requestKey: 'enterprise-v2:owner-1:org-1:12500:20000:24000:12:1250',
ownerUserId: 'owner-1',
organizationId: 'org-1',
requestedByEmail: 'admin@sim.ai',
@@ -137,6 +141,8 @@ function operationPayload(overrides: Record = {}) {
includedMonthlyCredits: 20000,
usageLimitCredits: 24000,
seats: 12,
+ concurrencyLimit: 1250,
+ pausePaymentCollection: false,
},
retryRevision: 0,
stripeProgress: {},
@@ -154,6 +160,28 @@ function context() {
}
describe('Enterprise issuance serialization decisions', () => {
+ it('preserves legacy request keys when no concurrency override is supplied', () => {
+ const input = {
+ ownerUserId: 'owner-1',
+ monthlyInvoiceAmountUsd: 125,
+ includedMonthlyCredits: 20000,
+ usageLimitCredits: 24000,
+ seats: 12,
+ requestedByEmail: 'admin@sim.ai',
+ requestedByUserId: 'admin-1',
+ }
+
+ expect(buildEnterpriseProvisioningRequestKey(input, 'org-1')).toBe(
+ 'enterprise-v2:owner-1:org-1:12500:20000:24000:12'
+ )
+ expect(
+ buildEnterpriseProvisioningRequestKey({ ...input, concurrencyLimit: 1250 }, 'org-1')
+ ).toBe('enterprise-v2:owner-1:org-1:12500:20000:24000:12:1250')
+ expect(
+ buildEnterpriseProvisioningRequestKey({ ...input, pausePaymentCollection: true }, 'org-1')
+ ).toBe('enterprise-v2:owner-1:org-1:12500:20000:24000:12:draft-collection')
+ })
+
it('deduplicates an identical unresolved request to the existing outbox operation', () => {
expect(
decideEnterpriseProvisioningIssue(
@@ -264,6 +292,8 @@ describe('Enterprise issuance outbox handler', () => {
metadata: { enterpriseOperationId: 'operation-1' },
})
mocks.subscriptionsCreate.mockResolvedValue({ id: 'sub_1' })
+ mocks.invoicesRetrieve.mockResolvedValue({ id: 'in_1', status: 'draft', auto_advance: true })
+ mocks.invoicesUpdate.mockResolvedValue({ id: 'in_1', status: 'draft', auto_advance: false })
})
it('creates one monthly send-invoice subscription and checkpoints progress', async () => {
@@ -295,6 +325,7 @@ describe('Enterprise issuance outbox handler', () => {
includedMonthlyCredits: '20000',
usageLimitCredits: '24000',
seats: '12',
+ concurrencyLimit: '1250',
}),
}),
{ idempotencyKey: 'enterprise:operation-1:subscription' }
@@ -339,6 +370,52 @@ describe('Enterprise issuance outbox handler', () => {
)
})
+ it('freezes the initial invoice and pauses collection indefinitely when requested', async () => {
+ arrangeWorkerReads()
+ mocks.subscriptionsCreate.mockResolvedValue({ id: 'sub_1', latest_invoice: 'in_1' })
+ mocks.subscriptionsUpdate.mockResolvedValue({ id: 'sub_1' })
+ const pausedPayload = operationPayload({
+ request: {
+ ...operationPayload().request,
+ requestKey: 'enterprise-v2:owner-1:org-1:12500:20000:24000:12:1250:draft-collection',
+ pausePaymentCollection: true,
+ },
+ })
+
+ await provisionEnterpriseInStripe(pausedPayload, context())
+
+ expect(mocks.invoicesUpdate).toHaveBeenCalledWith(
+ 'in_1',
+ { auto_advance: false },
+ { idempotencyKey: 'enterprise:operation-1:initial-invoice-draft' }
+ )
+ expect(mocks.subscriptionsUpdate).toHaveBeenCalledWith(
+ 'sub_1',
+ expect.objectContaining({
+ pause_collection: { behavior: 'keep_as_draft' },
+ }),
+ { idempotencyKey: 'enterprise:operation-1:pause-collection' }
+ )
+ })
+
+ it('fails closed instead of claiming a paused demo when its initial invoice finalized', async () => {
+ arrangeWorkerReads()
+ mocks.subscriptionsCreate.mockResolvedValue({ id: 'sub_1', latest_invoice: 'in_1' })
+ mocks.invoicesRetrieve.mockResolvedValue({ id: 'in_1', status: 'open', auto_advance: true })
+ const pausedPayload = operationPayload({
+ request: {
+ ...operationPayload().request,
+ requestKey: 'enterprise-v2:owner-1:org-1:12500:20000:24000:12:1250:draft-collection',
+ pausePaymentCollection: true,
+ },
+ })
+
+ await expect(provisionEnterpriseInStripe(pausedPayload, context())).rejects.toThrow(
+ 'initial invoice in_1 is already open'
+ )
+ expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
+ })
+
it('rejects a different live Stripe subscription before create', async () => {
arrangeWorkerReads()
mocks.subscriptionsList.mockResolvedValue({
@@ -419,6 +496,7 @@ describe('Enterprise metadata outbox handler', () => {
seats: 15,
includedMonthlyCredits: 30000,
usageLimitCredits: 35000,
+ concurrencyLimit: 1250,
},
}
mocks.select
@@ -445,6 +523,7 @@ describe('Enterprise metadata outbox handler', () => {
metadata: expect.objectContaining({
seats: '15',
includedMonthlyCredits: '30000',
+ concurrencyLimit: '1250',
simConfigRevision: '4',
simConfigOperationId: 'metadata-event-1',
simConfigDeliveryRevision: '0',
@@ -457,6 +536,48 @@ describe('Enterprise metadata outbox handler', () => {
)
})
+ it('unsets nullable metadata overrides in Stripe', async () => {
+ const payload = {
+ subscriptionId: 'local-sub-1',
+ revision: 5,
+ deliveryRevision: 0,
+ metadata: {
+ plan: 'enterprise',
+ referenceId: 'org-1',
+ seats: 15,
+ concurrencyLimit: null,
+ },
+ }
+ mocks.select
+ .mockReturnValueOnce(
+ selectChain([{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} }])
+ )
+ .mockReturnValueOnce(selectChain([{ metadata: {} }]))
+ .mockReturnValueOnce(selectChain([{ id: 'metadata-event-2', payload }]))
+ .mockReturnValueOnce(selectChain([{ value: 10 }]))
+ mocks.subscriptionsUpdate.mockResolvedValue({ id: 'sub_1' })
+
+ await expect(
+ syncEnterpriseMetadataInStripe(payload, {
+ eventId: 'metadata-event-2',
+ eventType: 'stripe.sync-enterprise-metadata',
+ attempts: 0,
+ checkpointPayload: vi.fn(),
+ })
+ ).rejects.toThrow('Awaiting verified Stripe webhook application')
+
+ expect(mocks.subscriptionsUpdate).toHaveBeenCalledWith(
+ 'sub_1',
+ {
+ metadata: expect.objectContaining({
+ concurrencyLimit: '',
+ simConfigOperationId: 'metadata-event-2',
+ }),
+ },
+ expect.any(Object)
+ )
+ })
+
it('suppresses an older metadata event after acquiring the subscription lease', async () => {
const payload = {
subscriptionId: 'local-sub-1',
diff --git a/apps/sim/lib/billing/enterprise-provisioning.ts b/apps/sim/lib/billing/enterprise-provisioning.ts
index 03d74674145..4c35487dd8f 100644
--- a/apps/sim/lib/billing/enterprise-provisioning.ts
+++ b/apps/sim/lib/billing/enterprise-provisioning.ts
@@ -4,6 +4,8 @@ import { member, organization, outboxEvent, subscription, user } from '@sim/db/s
import { generateId } from '@sim/utils/id'
import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import type Stripe from 'stripe'
+import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
+import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits'
import {
deriveEnterpriseOperationStatus,
ENTERPRISE_METADATA_SYNC_EVENT_TYPE,
@@ -235,6 +237,8 @@ export interface IssueEnterpriseProvisioningInput {
includedMonthlyCredits: number
usageLimitCredits?: number
seats: number
+ concurrencyLimit?: number
+ pausePaymentCollection?: boolean
requestedByEmail: string
requestedByUserId: string | null
}
@@ -248,6 +252,8 @@ export interface EnterpriseProvisioningView {
includedMonthlyCredits: number
usageLimitCredits: number
seats: number
+ concurrencyLimit: number
+ pausePaymentCollection: boolean
stripeSubscriptionId: string | null
error: string | null
createdAt: string
@@ -270,8 +276,12 @@ function slugifyOrganizationName(name: string, organizationId: string): string {
return `${base || 'organization'}-${organizationId.slice(-8)}`
}
-function buildRequestKey(input: IssueEnterpriseProvisioningInput, organizationId: string): string {
- return [
+/** Builds a commercial-terms key while preserving legacy keys when new terms are omitted. */
+export function buildEnterpriseProvisioningRequestKey(
+ input: IssueEnterpriseProvisioningInput,
+ organizationId: string
+): string {
+ const requestTerms: Array = [
'enterprise-v2',
input.ownerUserId,
organizationId,
@@ -279,7 +289,10 @@ function buildRequestKey(input: IssueEnterpriseProvisioningInput, organizationId
input.includedMonthlyCredits,
input.usageLimitCredits ?? input.includedMonthlyCredits,
input.seats,
- ].join(':')
+ ]
+ if (input.concurrencyLimit !== undefined) requestTerms.push(input.concurrencyLimit)
+ if (input.pausePaymentCollection) requestTerms.push('draft-collection')
+ return requestTerms.join(':')
}
function toEnterpriseProvisioningView(
@@ -297,6 +310,8 @@ function toEnterpriseProvisioningView(
includedMonthlyCredits: request.includedMonthlyCredits,
usageLimitCredits: request.usageLimitCredits,
seats: request.seats,
+ concurrencyLimit: getBillingConcurrencyLimit('enterprise', request.concurrencyLimit),
+ pausePaymentCollection: request.pausePaymentCollection,
stripeSubscriptionId:
payload.applicationResult?.subscriptionId ?? payload.stripeProgress.subscriptionId ?? null,
error: row.lastError,
@@ -407,6 +422,12 @@ export async function issueEnterpriseProvisioning(
'Monthly invoice amount must be at least $0.01 and use whole cents'
)
}
+ if (
+ input.concurrencyLimit !== undefined &&
+ parseBillingConcurrencyLimit(input.concurrencyLimit) !== input.concurrencyLimit
+ ) {
+ throw new EnterpriseProvisioningError('Concurrency limit is invalid')
+ }
const result = await db.transaction(async (tx) => {
const [owner] = await tx
.select({ id: user.id, name: user.name, email: user.email })
@@ -479,7 +500,7 @@ export async function issueEnterpriseProvisioning(
}
await acquireOrganizationMutationLock(tx, organizationId)
- const requestKey = buildRequestKey(input, organizationId)
+ const requestKey = buildEnterpriseProvisioningRequestKey(input, organizationId)
const [lockedOwner] = await tx
.select({ role: member.role })
.from(member)
@@ -530,6 +551,8 @@ export async function issueEnterpriseProvisioning(
includedMonthlyCredits: input.includedMonthlyCredits,
usageLimitCredits: input.usageLimitCredits ?? input.includedMonthlyCredits,
seats: input.seats,
+ ...(input.concurrencyLimit !== undefined ? { concurrencyLimit: input.concurrencyLimit } : {}),
+ pausePaymentCollection: input.pausePaymentCollection ?? false,
}
const payload: EnterpriseProvisionPayload = {
version: 1,
@@ -558,6 +581,8 @@ export async function issueEnterpriseProvisioning(
includedMonthlyCredits: view.includedMonthlyCredits,
usageLimitCredits: view.usageLimitCredits,
seats: view.seats,
+ concurrencyLimit: view.concurrencyLimit,
+ pausePaymentCollection: view.pausePaymentCollection,
status: view.status,
},
})
@@ -693,6 +718,43 @@ async function resolveCanonicalCustomer(params: {
return current.stripeCustomerId
}
+/**
+ * The subscription create call also creates its first invoice. Freeze that
+ * invoice before pausing collection so it cannot auto-finalize in the small
+ * interval between the two Stripe operations.
+ */
+async function keepInitialEnterpriseInvoiceAsDraft(params: {
+ stripe: Stripe
+ subscription: Stripe.Subscription
+ operationId: string
+}): Promise {
+ const latestInvoice = params.subscription.latest_invoice
+ if (!latestInvoice) {
+ throw new Error('Paused Enterprise subscription did not expose its initial invoice')
+ }
+ const invoiceId = typeof latestInvoice === 'string' ? latestInvoice : latestInvoice.id
+ if (!invoiceId) {
+ throw new Error('Paused Enterprise subscription initial invoice has no ID')
+ }
+
+ const invoice =
+ typeof latestInvoice === 'string'
+ ? await params.stripe.invoices.retrieve(invoiceId)
+ : latestInvoice
+ if (invoice.status !== 'draft') {
+ throw new Error(
+ `Paused Enterprise initial invoice ${invoiceId} is already ${invoice.status ?? 'in an unknown state'}`
+ )
+ }
+ if (invoice.auto_advance === false) return
+
+ await params.stripe.invoices.update(
+ invoiceId,
+ { auto_advance: false },
+ { idempotencyKey: `enterprise:${params.operationId}:initial-invoice-draft` }
+ )
+}
+
export const provisionEnterpriseInStripe: OutboxHandler = async (rawPayload, context) => {
const parsed = enterpriseProvisionPayloadSchema.safeParse(rawPayload)
if (!parsed.success) throw new Error('Invalid Enterprise issuance outbox payload')
@@ -765,6 +827,9 @@ export const provisionEnterpriseInStripe: OutboxHandler = async (rawPay
includedMonthlyCredits: request.includedMonthlyCredits.toString(),
usageLimitCredits: request.usageLimitCredits.toString(),
seats: request.seats.toString(),
+ ...(request.concurrencyLimit !== undefined
+ ? { concurrencyLimit: request.concurrencyLimit.toString() }
+ : {}),
}
// Recover the subscription before creating supporting catalog objects. This
@@ -900,7 +965,15 @@ export const provisionEnterpriseInStripe: OutboxHandler = async (rawPay
createdSubscription = true
}
- if (!createdSubscription) {
+ if (request.pausePaymentCollection) {
+ await keepInitialEnterpriseInvoiceAsDraft({
+ stripe,
+ subscription: stripeSubscription,
+ operationId: context.eventId,
+ })
+ }
+
+ if (!createdSubscription || request.pausePaymentCollection) {
stripeSubscription = await stripe.subscriptions.update(
stripeSubscription.id,
{
@@ -908,9 +981,12 @@ export const provisionEnterpriseInStripe: OutboxHandler = async (rawPay
...metadata,
enterpriseRetryRevision: payload.retryRevision.toString(),
},
+ pause_collection: request.pausePaymentCollection ? { behavior: 'keep_as_draft' } : '',
},
{
- idempotencyKey: `enterprise:${context.eventId}:retry:${payload.retryRevision}`,
+ idempotencyKey: createdSubscription
+ ? `enterprise:${context.eventId}:pause-collection`
+ : `enterprise:${context.eventId}:retry:${payload.retryRevision}`,
}
)
}
@@ -986,7 +1062,8 @@ export const syncEnterpriseMetadataInStripe: OutboxHandler = async (
const metadata: Record = {}
for (const [key, value] of Object.entries(latestPayload.data.metadata)) {
- if (value !== undefined && value !== null) metadata[key] = String(value)
+ if (value === null) metadata[key] = ''
+ else if (value !== undefined) metadata[key] = String(value)
}
metadata.simConfigRevision = String(latestPayload.data.revision)
metadata.simConfigOperationId = context.eventId
diff --git a/apps/sim/lib/billing/plan-helpers.test.ts b/apps/sim/lib/billing/plan-helpers.test.ts
new file mode 100644
index 00000000000..35d19b941c5
--- /dev/null
+++ b/apps/sim/lib/billing/plan-helpers.test.ts
@@ -0,0 +1,28 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
+
+describe('getPlanTypeForLimits', () => {
+ it.each([
+ ['pro_6000', 'pro'],
+ ['team_6000', 'pro'],
+ ['pro_25000', 'team'],
+ ['team_25000', 'team'],
+ ] as const)('buckets modern plan %s by paid tier into %s', (plan, expected) => {
+ expect(getPlanTypeForLimits(plan)).toBe(expected)
+ })
+
+ it('keeps legacy pro and team plan names in their original categories', () => {
+ expect(getPlanTypeForLimits('pro')).toBe('pro')
+ expect(getPlanTypeForLimits('team')).toBe('team')
+ })
+
+ it('maps enterprise, free, and unknown plans unchanged', () => {
+ expect(getPlanTypeForLimits('enterprise')).toBe('enterprise')
+ expect(getPlanTypeForLimits('free')).toBe('free')
+ expect(getPlanTypeForLimits(undefined)).toBe('free')
+ expect(getPlanTypeForLimits('unrecognized')).toBe('free')
+ })
+})
diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts
index f22b13c302e..b74ed0ff448 100644
--- a/apps/sim/lib/billing/plan-helpers.ts
+++ b/apps/sim/lib/billing/plan-helpers.ts
@@ -96,12 +96,17 @@ export function getPlanType(plan: string | null | undefined): PlanCategory {
}
/**
- * Return the plan category used for rate limits, storage, and execution timeouts.
- * Max plans (>= 25K credits) are promoted to team-level limits.
+ * Return the plan category used for plan-based limits (rate limits, storage,
+ * execution timeouts, concurrency, tables). Modern plans bucket by paid tier:
+ * Pro and Pro for Teams share `pro`, while Max and Max for Teams (>= 25K
+ * credits) share `team`. Legacy `pro`/`team` plan names keep their original
+ * categories.
*/
export function getPlanTypeForLimits(plan: string | null | undefined): PlanCategory {
- const credits = getPlanTierCredits(plan)
- if (credits >= 25000 && isPro(plan)) return 'team'
+ if (plan === 'pro' || plan === 'team') return getPlanType(plan)
+ if (isPro(plan) || isTeam(plan)) {
+ return getPlanTierCredits(plan) >= 25000 ? 'team' : 'pro'
+ }
return getPlanType(plan)
}
diff --git a/apps/sim/lib/billing/storage/limits.test.ts b/apps/sim/lib/billing/storage/limits.test.ts
index 1b78959e41f..c0850a29e97 100644
--- a/apps/sim/lib/billing/storage/limits.test.ts
+++ b/apps/sim/lib/billing/storage/limits.test.ts
@@ -46,8 +46,12 @@ vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
}))
+const { mockGetEnv } = vi.hoisted(() => ({
+ mockGetEnv: vi.fn((_variable: string): string | undefined => undefined),
+}))
+
vi.mock('@/lib/core/config/env', () => ({
- getEnv: vi.fn(() => undefined),
+ getEnv: mockGetEnv,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
@@ -88,6 +92,7 @@ describe('storage limits and quota', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFlags.isBillingEnabled = true
+ mockGetEnv.mockReturnValue(undefined)
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
@@ -168,6 +173,21 @@ describe('storage limits and quota', () => {
expect(mockSelect).not.toHaveBeenCalled()
})
+ it('opts into free-tier enforcement when FREE_STORAGE_LIMIT_GB is explicitly set', async () => {
+ mockFlags.isBillingEnabled = false
+ mockGetEnv.mockImplementation((variable: string) =>
+ variable === 'FREE_STORAGE_LIMIT_GB' ? '1' : undefined
+ )
+ mockLimit.mockResolvedValue([{ storageUsedBytes: GIB }])
+
+ await expect(checkStorageQuota('workspace-owner', GIB / 2)).resolves.toEqual({
+ allowed: false,
+ currentUsage: GIB,
+ error: 'Storage limit exceeded. Used: 1.50GB, Limit: 1GB',
+ limit: GIB,
+ })
+ })
+
it('fails closed with the exact fallback when either context resolution fails', async () => {
const expected = {
allowed: false,
diff --git a/apps/sim/lib/billing/storage/limits.ts b/apps/sim/lib/billing/storage/limits.ts
index 10430024c28..34b01d97b07 100644
--- a/apps/sim/lib/billing/storage/limits.ts
+++ b/apps/sim/lib/billing/storage/limits.ts
@@ -88,7 +88,9 @@ function readCustomStorageLimitGB(metadata: unknown): number | null {
/**
* Resolves a storage cap solely from normalized plan, scope, custom-limit, and
- * configured-limit inputs.
+ * configured-limit inputs. Paid plans bucket by tier via
+ * `getPlanTypeForLimits`; an organization's subscription metadata override
+ * takes precedence over its tier default.
*/
function resolveStorageLimit({
plan,
@@ -98,10 +100,10 @@ function resolveStorageLimit({
}: StorageLimitResolutionInput): number {
if (!plan || isFree(plan)) return limits.free
- if (scope === 'organization') {
- if (customStorageLimitGB !== null) return gbToBytes(customStorageLimitGB)
- return isEnterprise(plan) ? limits.enterpriseDefault : limits.team
+ if (scope === 'organization' && customStorageLimitGB !== null) {
+ return gbToBytes(customStorageLimitGB)
}
+ if (isEnterprise(plan)) return limits.enterpriseDefault
const effectivePlan = getPlanTypeForLimits(plan)
if (effectivePlan === 'pro') return limits.pro
@@ -131,11 +133,11 @@ function resolveLegacyStorageLimit(
* Evaluates quota behavior from resolved values without performing I/O.
*/
function evaluateStorageQuota(
- billingEnabled: boolean,
+ enforced: boolean,
additionalBytes: number,
snapshot: StorageQuotaSnapshot | null
): StorageQuotaResult {
- if (!billingEnabled) {
+ if (!enforced) {
return {
allowed: true,
currentUsage: 0,
@@ -164,6 +166,17 @@ function evaluateStorageQuota(
}
}
+/**
+ * Whether storage quotas are enforced. Always on when billing is enabled;
+ * with billing disabled, enforcement is opt-in by explicitly setting
+ * `FREE_STORAGE_LIMIT_GB` (all accounts resolve to the free tier there).
+ */
+export function isStorageEnforcementEnabled(): boolean {
+ if (isBillingEnabled) return true
+ const explicit = getEnv('FREE_STORAGE_LIMIT_GB')
+ return explicit != null && explicit !== '' && Number.parseInt(explicit) > 0
+}
+
/**
* Get storage limits from environment variables with fallback to constants
* Returns limits in bytes
@@ -308,7 +321,7 @@ async function checkStorageQuotaWithResolver(
resolveSnapshot: () => Promise,
logResolutionError: (error: unknown) => void
): Promise {
- if (!isBillingEnabled) {
+ if (!isStorageEnforcementEnabled()) {
return evaluateStorageQuota(false, additionalBytes, null)
}
@@ -322,7 +335,8 @@ async function checkStorageQuotaWithResolver(
/**
* Check if user has storage quota available.
- * Always allows uploads when billing is disabled.
+ * Billing-disabled deployments allow all uploads unless the operator
+ * explicitly opted into enforcement via `FREE_STORAGE_LIMIT_GB`.
*/
export async function checkStorageQuota(
userId: string,
diff --git a/apps/sim/lib/billing/storage/tracking.test.ts b/apps/sim/lib/billing/storage/tracking.test.ts
index 684f25ef0c7..40cbcf664d6 100644
--- a/apps/sim/lib/billing/storage/tracking.test.ts
+++ b/apps/sim/lib/billing/storage/tracking.test.ts
@@ -91,6 +91,8 @@ vi.mock('@/lib/billing/storage/limits', () => ({
getStorageUsageForBillingContext: mockGetStorageUsageForBillingContext,
getUserStorageLimit: mockGetUserStorageLimit,
getUserStorageUsage: mockGetUserStorageUsage,
+ // No FREE_STORAGE_LIMIT_GB opt-in in these tests, so enforcement === billing.
+ isStorageEnforcementEnabled: () => mockFlags.isBillingEnabled,
}))
vi.mock('@/lib/core/config/env', () => ({
diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts
index f5628d987bf..1fab48fa652 100644
--- a/apps/sim/lib/billing/storage/tracking.ts
+++ b/apps/sim/lib/billing/storage/tracking.ts
@@ -17,9 +17,9 @@ import {
getStorageUsageForBillingContext,
getUserStorageLimit,
getUserStorageUsage,
+ isStorageEnforcementEnabled,
} from '@/lib/billing/storage/limits'
import { getFreeTierLimit, isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
-import { isBillingEnabled } from '@/lib/core/config/env-flags'
import type { DbOrTx } from '@/lib/db/types'
const logger = createLogger('StorageTracking')
@@ -281,7 +281,7 @@ export async function applyStorageUsageDeltasInTx(
addPayerDelta(
billingEntity,
delta.deltaBytes,
- delta.deltaBytes > 0 && isBillingEnabled
+ delta.deltaBytes > 0 && isStorageEnforcementEnabled()
? getStorageLimitForBillingContext(delta.context)
: undefined,
true
@@ -294,7 +294,7 @@ export async function applyStorageUsageDeltasInTx(
}
const billingEntity = getLegacyStorageBillingEntity(delta.userId, delta.subscription)
const maximumUsage =
- delta.deltaBytes > 0 && isBillingEnabled
+ delta.deltaBytes > 0 && isStorageEnforcementEnabled()
? await getUserStorageLimit(delta.userId, delta.subscription)
: undefined
addPayerDelta(billingEntity, delta.deltaBytes, maximumUsage, delta.deltaBytes > 0)
@@ -509,7 +509,7 @@ export async function maybeNotifyStorageLimitForBillingContext(
updatedUsage?: number,
rearmOnly = false
): Promise {
- if (!isBillingEnabled) return
+ if (!isStorageEnforcementEnabled()) return
try {
const [usage, limit] = await Promise.all([
@@ -559,7 +559,9 @@ export async function incrementStorageUsageForBillingContextInTx(
bytes: number
): Promise {
if (bytes <= 0) return undefined
- const limit = isBillingEnabled ? getStorageLimitForBillingContext(context) : undefined
+ const limit = isStorageEnforcementEnabled()
+ ? getStorageLimitForBillingContext(context)
+ : undefined
const result = await mutateWorkspaceStorageUsage(
tx,
context.workspaceId,
@@ -598,7 +600,7 @@ export async function checkAndIncrementStorageUsageInTx(
userId: string,
bytes: number
): Promise<{ allowed: boolean; currentUsage: number; limit: number; error?: string }> {
- if (!isBillingEnabled) {
+ if (!isStorageEnforcementEnabled()) {
return { allowed: true, currentUsage: 0, limit: Number.MAX_SAFE_INTEGER }
}
diff --git a/apps/sim/lib/billing/types/index.test.ts b/apps/sim/lib/billing/types/index.test.ts
new file mode 100644
index 00000000000..11a560edb7c
--- /dev/null
+++ b/apps/sim/lib/billing/types/index.test.ts
@@ -0,0 +1,49 @@
+/** @vitest-environment node */
+
+import { describe, expect, it } from 'vitest'
+import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'
+import { parseEnterpriseSubscriptionMetadata } from '@/lib/billing/types'
+
+const REQUIRED_METADATA = {
+ plan: 'enterprise',
+ referenceId: 'org-1',
+ monthlyPrice: '500',
+ seats: '25',
+}
+
+describe('Enterprise subscription metadata', () => {
+ it('normalizes the canonical payer concurrency override', () => {
+ expect(
+ parseEnterpriseSubscriptionMetadata({
+ ...REQUIRED_METADATA,
+ concurrencyLimit: '1250',
+ })
+ ).toMatchObject({
+ plan: 'enterprise',
+ concurrencyLimit: 1250,
+ })
+ })
+
+ it('rejects concurrency overrides above the operational safety bound', () => {
+ expect(
+ parseEnterpriseSubscriptionMetadata({
+ ...REQUIRED_METADATA,
+ concurrencyLimit: MAX_BILLING_CONCURRENCY_LIMIT + 1,
+ })
+ ).toBeNull()
+ })
+
+ it('does not expose the unused workspace-scoped metadata field', () => {
+ expect(
+ parseEnterpriseSubscriptionMetadata({
+ ...REQUIRED_METADATA,
+ workspaceConcurrencyLimit: 1250,
+ })
+ ).toEqual({
+ plan: 'enterprise',
+ referenceId: 'org-1',
+ monthlyPrice: 500,
+ seats: 25,
+ })
+ })
+})
diff --git a/apps/sim/lib/billing/types/index.ts b/apps/sim/lib/billing/types/index.ts
index c90d0991c98..46558c32bc3 100644
--- a/apps/sim/lib/billing/types/index.ts
+++ b/apps/sim/lib/billing/types/index.ts
@@ -3,6 +3,7 @@
* Centralized type definitions for the billing system
*/
import { z } from 'zod'
+import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'
export const enterpriseSubscriptionMetadataSchema = z.object({
plan: z
@@ -17,20 +18,16 @@ export const enterpriseSubscriptionMetadataSchema = z.object({
monthlyPrice: z.coerce.number().positive(),
// Number of seats for invitation limits (not for billing)
seats: z.coerce.number().int().positive(),
- // Optional custom workspace concurrency limit for enterprise workspaces
- workspaceConcurrencyLimit: z.coerce.number().int().positive().optional(),
+ concurrencyLimit: z.coerce
+ .number()
+ .int()
+ .positive()
+ .max(MAX_BILLING_CONCURRENCY_LIMIT)
+ .optional(),
})
export type EnterpriseSubscriptionMetadata = z.infer
-const enterpriseWorkspaceConcurrencyMetadataSchema = z.object({
- workspaceConcurrencyLimit: z.coerce.number().int().positive().optional(),
-})
-
-export type EnterpriseWorkspaceConcurrencyMetadata = z.infer<
- typeof enterpriseWorkspaceConcurrencyMetadataSchema
->
-
export function parseEnterpriseSubscriptionMetadata(
value: unknown
): EnterpriseSubscriptionMetadata | null {
@@ -38,13 +35,6 @@ export function parseEnterpriseSubscriptionMetadata(
return result.success ? result.data : null
}
-export function parseEnterpriseWorkspaceConcurrencyMetadata(
- value: unknown
-): EnterpriseWorkspaceConcurrencyMetadata | null {
- const result = enterpriseWorkspaceConcurrencyMetadataSchema.safeParse(value)
- return result.success ? result.data : null
-}
-
export interface UsageData {
currentUsage: number
limit: number
diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts
index 06ccaf4e4d4..b2a46f0b893 100644
--- a/apps/sim/lib/compare/data/sim.ts
+++ b/apps/sim/lib/compare/data/sim.ts
@@ -1106,12 +1106,17 @@ export const simProfile: CompetitorProfile = {
},
executionLimits: {
value:
- 'Plan-gated: synchronous API calls time out at 5 minutes on the free plan and 50 minutes on paid plans, async calls at 90 minutes on every plan, with 15 to 300 concurrent executions per billing entity depending on plan',
+ 'Plan-gated: synchronous API calls time out at 5 minutes on the free plan and 50 minutes on paid plans, async calls at 90 minutes on every plan, with 10 to 1,000 concurrent executions per billing account depending on plan',
detail:
- 'These limits are not published in docs; request bodies are separately capped at 10 MB.',
- shortValue: '5-50 min sync timeout, 90 min async, 15-300 concurrent',
+ 'Concurrency limits are published in the platform cost docs, and Enterprise limits are customizable. Request bodies are separately capped at 10 MB.',
+ shortValue: '5-50 min sync timeout, 90 min async, 10-1,000 concurrent',
confidence: 'verified',
sources: [
+ {
+ url: 'https://docs.sim.ai/platform/costs#concurrent-executions',
+ label: 'Sim Docs: Concurrent Executions',
+ asOf: '2026-07-13',
+ },
{
url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/lib/core/execution-limits/types.ts',
label: 'Sim codebase: per-plan execution timeouts',
@@ -1120,7 +1125,7 @@ export const simProfile: CompetitorProfile = {
{
url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/lib/billing/calculations/usage-reservation.ts',
label: 'Sim codebase: max concurrent executions per plan',
- asOf: '2026-07-02',
+ asOf: '2026-07-13',
},
],
},
diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts
index 99189a39c4b..abcd14d5ef9 100644
--- a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts
+++ b/apps/sim/lib/copilot/request/tools/workflow-context.test.ts
@@ -57,7 +57,16 @@ const childBillingAttribution: BillingAttributionSnapshot = Object.freeze({
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
},
- payerSubscription: null,
+ payerSubscription: {
+ id: 'subscription-2',
+ plan: 'enterprise',
+ referenceId: 'organization-2',
+ status: 'active',
+ periodStart: '2026-07-01T00:00:00.000Z',
+ periodEnd: '2026-08-01T00:00:00.000Z',
+ seats: 25,
+ enterpriseConcurrencyLimit: 1250,
+ },
})
function createContext(): ExecutionContext {
@@ -190,6 +199,28 @@ describe('prepareWorkflowExecutionAdmission', () => {
isExceeded: false,
payerUsage: { currentUsage: 1, limit: 10 },
})
+ reserveExecutionSlotMock.mockResolvedValue({ reserved: true, created: true })
+ })
+
+ it('forwards the target Enterprise concurrency override to admission', async () => {
+ const result = await prepareWorkflowExecutionAdmission(
+ createContext(),
+ 'workspace-2',
+ 'child-execution-1'
+ )
+
+ expect(result).toEqual({
+ billingAttribution: childBillingAttribution,
+ targetReservation: true,
+ })
+ expect(reserveExecutionSlotMock).toHaveBeenCalledWith({
+ billingEntity: { type: 'organization', id: 'organization-2' },
+ executionId: 'child-execution-1',
+ plan: 'enterprise',
+ enterpriseConcurrencyLimit: 1250,
+ currentUsage: 1,
+ limit: 10,
+ })
})
it.each([
diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.ts b/apps/sim/lib/copilot/request/tools/workflow-context.ts
index 80c3d19502b..0e8fa1523d6 100644
--- a/apps/sim/lib/copilot/request/tools/workflow-context.ts
+++ b/apps/sim/lib/copilot/request/tools/workflow-context.ts
@@ -158,6 +158,7 @@ export async function prepareWorkflowExecutionAdmission(
billingEntity: billingAttribution.billingEntity,
executionId: childExecutionId,
plan: billingAttribution.payerSubscription?.plan,
+ enterpriseConcurrencyLimit: billingAttribution.payerSubscription?.enterpriseConcurrencyLimit,
currentUsage: payerUsage.currentUsage,
limit: payerUsage.limit,
...(billingAttribution.organizationId &&
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index e38cb4840eb..6fcd4dd7c75 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -72,7 +72,7 @@ export const env = createEnv({
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), // General Stripe webhook secret
STRIPE_FREE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for free tier
FREE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for free tier users
- FREE_STORAGE_LIMIT_GB: z.number().optional().default(5), // Storage limit in GB for free tier users
+ FREE_STORAGE_LIMIT_GB: z.number().optional(), // Free-tier storage limit in GB (default 5). With billing disabled, setting it explicitly opts into storage enforcement
STRIPE_PRO_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for pro tier
PRO_TIER_COST_LIMIT: z.number().optional(), // Cost limit for pro tier users
PRO_STORAGE_LIMIT_GB: z.number().optional().default(50), // Storage limit in GB for pro tier users
@@ -82,6 +82,10 @@ export const env = createEnv({
STRIPE_ENTERPRISE_PRICE_ID: z.string().min(1).optional(), // Stripe price ID for enterprise tier
ENTERPRISE_TIER_COST_LIMIT: z.number().optional(), // Cost limit for enterprise tier users
ENTERPRISE_STORAGE_LIMIT_GB: z.number().optional().default(500), // Default storage limit in GB for enterprise tier (can be overridden per org)
+ BILLING_CONCURRENCY_LIMIT_FREE: z.string().optional(), // In-flight executions per free billing account
+ BILLING_CONCURRENCY_LIMIT_PRO: z.string().optional(), // In-flight executions per Pro-tier billing account (Pro and Pro for Teams)
+ BILLING_CONCURRENCY_LIMIT_TEAM: z.string().optional(), // In-flight executions per Max-tier billing account (Max and Max for Teams)
+ BILLING_CONCURRENCY_LIMIT_ENTERPRISE: z.string().optional(), // In-flight executions per Enterprise billing account (metadata-overridable)
BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking
FREE_API_DEPLOYMENT_GATE_ENABLED: z.boolean().optional(), // Block free-plan accounts from programmatic execution (API/MCP/A2A/generic webhooks/chat embeds). Requires BILLING_ENABLED. Off by default for dark rollout
TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap
@@ -280,8 +284,9 @@ export const env = createEnv({
// Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
- RATE_LIMIT_FREE_SYNC: z.string().optional().default('50'), // Free tier sync API executions per minute
- RATE_LIMIT_FREE_ASYNC: z.string().optional().default('200'), // Free tier async API executions per minute
+ RATE_LIMIT_FREE_SYNC: z.string().optional(), // Free tier sync API executions per minute (default 50). With billing disabled, setting it explicitly opts into rate limiting
+ RATE_LIMIT_FREE_ASYNC: z.string().optional(), // Free tier async API executions per minute (default 200). With billing disabled, setting it explicitly opts into rate limiting
+ RATE_LIMIT_FREE_API_ENDPOINT: z.string().optional(), // Free tier v1 API endpoint requests per minute (default 30). With billing disabled, setting it explicitly opts into rate limiting
RATE_LIMIT_PRO_SYNC: z.string().optional().default('150'), // Pro tier sync API executions per minute
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('1000'), // Pro tier async API executions per minute
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('300'), // Team tier sync API executions per minute
@@ -289,11 +294,11 @@ export const env = createEnv({
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute
// Timeout Configuration
- EXECUTION_TIMEOUT_FREE: z.string().optional().default('300'), // 5 minutes
+ EXECUTION_TIMEOUT_FREE: z.string().optional(), // Free tier sync timeout in seconds (default 300). With billing disabled, setting it explicitly opts into sync timeouts
EXECUTION_TIMEOUT_PRO: z.string().optional().default('3000'), // 50 minutes
EXECUTION_TIMEOUT_TEAM: z.string().optional().default('3000'), // 50 minutes
EXECUTION_TIMEOUT_ENTERPRISE: z.string().optional().default('3000'), // 50 minutes
- EXECUTION_TIMEOUT_ASYNC_FREE: z.string().optional().default('5400'), // 90 minutes
+ EXECUTION_TIMEOUT_ASYNC_FREE: z.string().optional(), // Free tier async timeout in seconds (default 5400). With billing disabled, setting it explicitly opts into async timeouts
EXECUTION_TIMEOUT_ASYNC_PRO: z.string().optional().default('5400'), // 90 minutes
EXECUTION_TIMEOUT_ASYNC_TEAM: z.string().optional().default('5400'), // 90 minutes
EXECUTION_TIMEOUT_ASYNC_ENTERPRISE: z.string().optional().default('5400'), // 90 minutes
diff --git a/apps/sim/lib/core/execution-limits/types.test.ts b/apps/sim/lib/core/execution-limits/types.test.ts
new file mode 100644
index 00000000000..0b61b5a3efd
--- /dev/null
+++ b/apps/sim/lib/core/execution-limits/types.test.ts
@@ -0,0 +1,69 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Free-tier timeouts are baked into EXECUTION_TIMEOUTS at module load, while
+ * the billing-disabled opt-in check reads the env at call time. Seeding here
+ * mirrors production, where both reads observe the same process env.
+ */
+const { mockEnv, mockFlags } = vi.hoisted(() => ({
+ mockEnv: {
+ EXECUTION_TIMEOUT_FREE: '120',
+ EXECUTION_TIMEOUT_ASYNC_FREE: '240',
+ } as Record,
+ mockFlags: { isBillingEnabled: true },
+}))
+
+vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
+vi.mock('@/lib/core/config/env-flags', () => ({
+ get isBillingEnabled() {
+ return mockFlags.isBillingEnabled
+ },
+}))
+
+import { createTimeoutAbortController, getExecutionTimeout } from '@/lib/core/execution-limits'
+
+describe('getExecutionTimeout', () => {
+ beforeEach(() => {
+ mockFlags.isBillingEnabled = true
+ mockEnv.EXECUTION_TIMEOUT_FREE = '120'
+ mockEnv.EXECUTION_TIMEOUT_ASYNC_FREE = '240'
+ })
+
+ it('applies per-tier timeouts when billing is enabled', () => {
+ expect(getExecutionTimeout('pro_6000', 'sync')).toBe(3000 * 1000)
+ expect(getExecutionTimeout('team_25000', 'sync')).toBe(3000 * 1000)
+ expect(getExecutionTimeout('free', 'sync')).toBe(120 * 1000)
+ })
+
+ it('disables timeouts when billing is disabled and no free env is set', () => {
+ mockFlags.isBillingEnabled = false
+ mockEnv.EXECUTION_TIMEOUT_FREE = undefined
+ mockEnv.EXECUTION_TIMEOUT_ASYNC_FREE = undefined
+
+ expect(getExecutionTimeout('free', 'sync')).toBe(0)
+ expect(getExecutionTimeout('free', 'async')).toBe(0)
+ })
+
+ it('opts back into the free timeout when the env var is explicitly set', () => {
+ mockFlags.isBillingEnabled = false
+
+ expect(getExecutionTimeout('free', 'sync')).toBe(120 * 1000)
+ expect(getExecutionTimeout('free', 'async')).toBe(240 * 1000)
+ })
+
+ it('never schedules an abort for a zero (disabled) timeout', () => {
+ vi.useFakeTimers()
+ try {
+ const controller = createTimeoutAbortController(0)
+ vi.advanceTimersByTime(24 * 60 * 60 * 1000)
+ expect(controller.signal.aborted).toBe(false)
+ expect(controller.isTimedOut()).toBe(false)
+ controller.cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts
index a76461e54d5..d11ddc22892 100644
--- a/apps/sim/lib/core/execution-limits/types.ts
+++ b/apps/sim/lib/core/execution-limits/types.ts
@@ -61,12 +61,21 @@ const EXECUTION_TIMEOUTS: Record = {
},
}
+/**
+ * Per-plan execution timeout in milliseconds; `0` means no timeout.
+ * Billing-disabled deployments run untimed unless the operator explicitly set
+ * the free-tier env var (`EXECUTION_TIMEOUT_FREE` /
+ * `EXECUTION_TIMEOUT_ASYNC_FREE`), which opts back into that bound.
+ */
export function getExecutionTimeout(
plan: SubscriptionPlan | string | undefined,
type: 'sync' | 'async' = 'sync'
): number {
if (!isBillingEnabled) {
- return EXECUTION_TIMEOUTS.free[type]
+ const override = Number.parseInt(
+ (type === 'sync' ? env.EXECUTION_TIMEOUT_FREE : env.EXECUTION_TIMEOUT_ASYNC_FREE) || ''
+ )
+ return Number.isFinite(override) && override > 0 ? EXECUTION_TIMEOUTS.free[type] : 0
}
return EXECUTION_TIMEOUTS[getPlanTypeForLimits(plan)][type]
}
diff --git a/apps/sim/lib/core/rate-limiter/types.test.ts b/apps/sim/lib/core/rate-limiter/types.test.ts
new file mode 100644
index 00000000000..1a33c9c642a
--- /dev/null
+++ b/apps/sim/lib/core/rate-limiter/types.test.ts
@@ -0,0 +1,59 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Free-tier env values are baked into RATE_LIMITS at module load, while the
+ * billing-disabled opt-in check reads them at call time. Seeding them here
+ * mirrors production, where both reads observe the same process env.
+ */
+const { mockEnv, mockFlags } = vi.hoisted(() => ({
+ mockEnv: {
+ RATE_LIMIT_FREE_SYNC: '25',
+ RATE_LIMIT_FREE_API_ENDPOINT: '10',
+ } as Record,
+ mockFlags: { isBillingEnabled: true },
+}))
+
+vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
+vi.mock('@/lib/core/config/env-flags', () => ({
+ get isBillingEnabled() {
+ return mockFlags.isBillingEnabled
+ },
+}))
+
+import { getRateLimit } from '@/lib/core/rate-limiter/types'
+
+describe('getRateLimit', () => {
+ beforeEach(() => {
+ mockFlags.isBillingEnabled = true
+ mockEnv.RATE_LIMIT_FREE_SYNC = '25'
+ mockEnv.RATE_LIMIT_FREE_API_ENDPOINT = '10'
+ })
+
+ it('applies per-tier limits when billing is enabled', () => {
+ expect(getRateLimit('pro_6000', 'sync').refillRate).toBe(150)
+ expect(getRateLimit('team_6000', 'sync').refillRate).toBe(150)
+ expect(getRateLimit('pro_25000', 'sync').refillRate).toBe(300)
+ expect(getRateLimit('team_25000', 'sync').refillRate).toBe(300)
+ })
+
+ it('is effectively unlimited when billing is disabled and no free env is set', () => {
+ mockFlags.isBillingEnabled = false
+ mockEnv.RATE_LIMIT_FREE_SYNC = undefined
+ mockEnv.RATE_LIMIT_FREE_API_ENDPOINT = undefined
+
+ expect(getRateLimit('free', 'sync').refillRate).toBe(999999)
+ expect(getRateLimit('free', 'async').refillRate).toBe(999999)
+ expect(getRateLimit('free', 'api-endpoint').refillRate).toBe(999999)
+ })
+
+ it('opts back into enforcement per counter when a free env var is explicitly set', () => {
+ mockFlags.isBillingEnabled = false
+
+ expect(getRateLimit('free', 'sync').refillRate).toBe(25)
+ expect(getRateLimit('free', 'api-endpoint').refillRate).toBe(10)
+ expect(getRateLimit('free', 'async').refillRate).toBe(999999)
+ })
+})
diff --git a/apps/sim/lib/core/rate-limiter/types.ts b/apps/sim/lib/core/rate-limiter/types.ts
index dbf023af410..0e999af3f00 100644
--- a/apps/sim/lib/core/rate-limiter/types.ts
+++ b/apps/sim/lib/core/rate-limiter/types.ts
@@ -46,7 +46,7 @@ function getRateLimitForPlan(plan: SubscriptionPlan, type: RateLimitConfigKey):
free: {
sync: env.RATE_LIMIT_FREE_SYNC,
async: env.RATE_LIMIT_FREE_ASYNC,
- apiEndpoint: undefined,
+ apiEndpoint: env.RATE_LIMIT_FREE_API_ENDPOINT,
},
pro: { sync: env.RATE_LIMIT_PRO_SYNC, async: env.RATE_LIMIT_PRO_ASYNC, apiEndpoint: undefined },
team: {
@@ -88,13 +88,31 @@ export const RATE_LIMITS: Record = {
},
}
+/** Effectively-unlimited bucket used when billing-disabled deployments opt out of a limit. */
+const UNLIMITED_RATE_LIMIT: TokenBucketConfig = createBucketConfig(MANUAL_EXECUTION_LIMIT, 1)
+
+function freeRateLimitOverride(type: RateLimitConfigKey): string | undefined {
+ const overrides: Record = {
+ sync: env.RATE_LIMIT_FREE_SYNC,
+ async: env.RATE_LIMIT_FREE_ASYNC,
+ apiEndpoint: env.RATE_LIMIT_FREE_API_ENDPOINT,
+ }
+ return overrides[type]
+}
+
+/**
+ * Billing-enabled deployments enforce per-plan limits. Billing-disabled
+ * deployments are unlimited unless the operator explicitly set the free-tier
+ * env var for that counter, which opts back into enforcement at that rate.
+ */
export function getRateLimit(
plan: SubscriptionPlan | string | undefined,
type: RateLimitCounterType
): TokenBucketConfig {
const key = toConfigKey(type)
if (!isBillingEnabled) {
- return RATE_LIMITS.free[key]
+ const override = Number.parseInt(freeRateLimitOverride(key) || '')
+ return Number.isFinite(override) && override > 0 ? RATE_LIMITS.free[key] : UNLIMITED_RATE_LIMIT
}
return RATE_LIMITS[getPlanTypeForLimits(plan)][key]
}
diff --git a/apps/sim/lib/execution/preprocessing.test.ts b/apps/sim/lib/execution/preprocessing.test.ts
index 1566463fec5..b8a2e8e3c24 100644
--- a/apps/sim/lib/execution/preprocessing.test.ts
+++ b/apps/sim/lib/execution/preprocessing.test.ts
@@ -542,6 +542,32 @@ describe('preprocessExecution billing attribution', () => {
})
})
+ it('forwards the frozen Enterprise concurrency override to admission', async () => {
+ const enterpriseAttribution = {
+ ...ORGANIZATION_ATTRIBUTION,
+ payerSubscription: {
+ ...ORGANIZATION_ATTRIBUTION.payerSubscription,
+ plan: 'enterprise',
+ enterpriseConcurrencyLimit: 1250,
+ },
+ }
+
+ const result = await preprocessExecution({
+ ...baseOptions,
+ userId: 'ignored-current-user',
+ useAuthenticatedUserAsActor: false,
+ billingAttribution: enterpriseAttribution,
+ })
+
+ expect(result.success).toBe(true)
+ expect(mockReserveExecutionSlot).toHaveBeenCalledWith(
+ expect.objectContaining({
+ plan: 'enterprise',
+ enterpriseConcurrencyLimit: 1250,
+ })
+ )
+ })
+
it('reuses a serialized attribution snapshot without re-resolving the payer', async () => {
const result = await preprocessExecution({
...baseOptions,
diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts
index 4bc67b0c6fa..7b74e1ca1d8 100644
--- a/apps/sim/lib/execution/preprocessing.ts
+++ b/apps/sim/lib/execution/preprocessing.ts
@@ -658,6 +658,8 @@ export async function preprocessExecution(
billingEntity: billingAttribution.billingEntity,
reservationId,
plan: billingAttribution.payerSubscription?.plan,
+ enterpriseConcurrencyLimit:
+ billingAttribution.payerSubscription?.enterpriseConcurrencyLimit,
currentUsage: usageSnapshot.currentUsage,
limit: usageSnapshot.limit,
...(billingAttribution.organizationId && usageSnapshot.memberUsage
diff --git a/apps/sim/lib/table/billing.test.ts b/apps/sim/lib/table/billing.test.ts
index 9f25d31e97f..ec71d49c9bf 100644
--- a/apps/sim/lib/table/billing.test.ts
+++ b/apps/sim/lib/table/billing.test.ts
@@ -3,12 +3,19 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockResolveWorkspaceBillingPayer, mockGetPlanTypeForLimits, mockGetTablePlanLimits } =
- vi.hoisted(() => ({
- mockResolveWorkspaceBillingPayer: vi.fn(),
- mockGetPlanTypeForLimits: vi.fn(),
- mockGetTablePlanLimits: vi.fn(),
- }))
+const {
+ mockFlags,
+ mockResolveWorkspaceBillingPayer,
+ mockGetPlanTypeForLimits,
+ mockGetBillingDisabledTableLimits,
+ mockGetTablePlanLimits,
+} = vi.hoisted(() => ({
+ mockFlags: { isBillingEnabled: true },
+ mockResolveWorkspaceBillingPayer: vi.fn(),
+ mockGetPlanTypeForLimits: vi.fn(),
+ mockGetBillingDisabledTableLimits: vi.fn(),
+ mockGetTablePlanLimits: vi.fn(),
+}))
vi.mock('@/lib/billing/core/billing-attribution', () => ({
resolveWorkspaceBillingPayer: mockResolveWorkspaceBillingPayer,
@@ -16,7 +23,13 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
vi.mock('@/lib/billing/plan-helpers', () => ({
getPlanTypeForLimits: mockGetPlanTypeForLimits,
}))
+vi.mock('@/lib/core/config/env-flags', () => ({
+ get isBillingEnabled() {
+ return mockFlags.isBillingEnabled
+ },
+}))
vi.mock('@/lib/table/constants', () => ({
+ getBillingDisabledTableLimits: mockGetBillingDisabledTableLimits,
getTablePlanLimits: mockGetTablePlanLimits,
}))
@@ -43,7 +56,12 @@ const nextWorkspaceId = () => `ws-${++wsCounter}`
beforeEach(() => {
vi.clearAllMocks()
+ mockFlags.isBillingEnabled = true
mockGetTablePlanLimits.mockReturnValue(LIMITS)
+ mockGetBillingDisabledTableLimits.mockReturnValue({
+ maxTables: Number.MAX_SAFE_INTEGER,
+ maxRowsPerTable: Number.MAX_SAFE_INTEGER,
+ })
mockResolveWorkspaceBillingPayer.mockResolvedValue({
billedAccountUserId: 'billed-user',
organizationId: 'org-1',
@@ -77,6 +95,21 @@ describe('getWorkspaceTableLimits', () => {
expect(await getWorkspaceTableLimits(ws)).toEqual(LIMITS.pro)
})
+ it('bypasses billing plan resolution entirely when billing is disabled', async () => {
+ mockFlags.isBillingEnabled = false
+ mockGetBillingDisabledTableLimits.mockReturnValue({
+ maxTables: Number.MAX_SAFE_INTEGER,
+ maxRowsPerTable: 12345,
+ })
+
+ expect(await getWorkspaceTableLimits(nextWorkspaceId())).toEqual({
+ maxTables: Number.MAX_SAFE_INTEGER,
+ maxRowsPerTable: 12345,
+ })
+ expect(mockResolveWorkspaceBillingPayer).not.toHaveBeenCalled()
+ expect(mockGetTablePlanLimits).not.toHaveBeenCalled()
+ })
+
it('stays bounded under a burst of distinct all-fresh workspaces', async () => {
// Far more distinct workspaces than the cap, all within one TTL window. The Map
// must not grow without limit; eviction keeps it at/under the ceiling.
diff --git a/apps/sim/lib/table/billing.ts b/apps/sim/lib/table/billing.ts
index 8e0fa394b06..af4aa9ee168 100644
--- a/apps/sim/lib/table/billing.ts
+++ b/apps/sim/lib/table/billing.ts
@@ -8,7 +8,13 @@ import { createLogger } from '@sim/logger'
import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution'
import { maybeNotifyLimit } from '@/lib/billing/core/limit-notifications'
import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
-import { getTablePlanLimits, type PlanName, type TablePlanLimits } from '@/lib/table/constants'
+import { isBillingEnabled } from '@/lib/core/config/env-flags'
+import {
+ getBillingDisabledTableLimits,
+ getTablePlanLimits,
+ type PlanName,
+ type TablePlanLimits,
+} from '@/lib/table/constants'
const logger = createLogger('TableBilling')
@@ -115,6 +121,10 @@ export function invalidateWorkspaceTableLimitsCache(workspaceId: string): void {
* @returns Table limits based on the workspace's billing plan
*/
export async function getWorkspaceTableLimits(workspaceId: string): Promise {
+ if (!isBillingEnabled) {
+ return getBillingDisabledTableLimits()
+ }
+
const cached = limitsCache.get(workspaceId)
if (cached) {
if (cached.expiresAt > Date.now()) return cached.limits
diff --git a/apps/sim/lib/table/constants.test.ts b/apps/sim/lib/table/constants.test.ts
new file mode 100644
index 00000000000..bc067916b7a
--- /dev/null
+++ b/apps/sim/lib/table/constants.test.ts
@@ -0,0 +1,55 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockEnv } = vi.hoisted(() => ({
+ mockEnv: {} as Record,
+}))
+
+vi.mock('@/lib/core/config/env', () => ({
+ env: mockEnv,
+ envNumber: (
+ value: number | string | undefined | null,
+ fallback: number,
+ options: { min?: number; integer?: boolean } = {}
+ ) => {
+ const parsed = Number(value)
+ const min = options.min ?? 0
+ return Number.isFinite(parsed) &&
+ parsed >= min &&
+ (!options.integer || Number.isInteger(parsed))
+ ? parsed
+ : fallback
+ },
+}))
+
+import { getBillingDisabledTableLimits } from '@/lib/table/constants'
+
+describe('getBillingDisabledTableLimits', () => {
+ beforeEach(() => {
+ for (const key of Object.keys(mockEnv)) delete mockEnv[key]
+ })
+
+ it('is unlimited when no free-tier env vars are set', () => {
+ expect(getBillingDisabledTableLimits()).toEqual({
+ maxTables: Number.MAX_SAFE_INTEGER,
+ maxRowsPerTable: Number.MAX_SAFE_INTEGER,
+ })
+ })
+
+ it('opts each cap back in independently when its env var is explicitly set', () => {
+ mockEnv.FREE_TABLES_LIMIT = '7'
+
+ expect(getBillingDisabledTableLimits()).toEqual({
+ maxTables: 7,
+ maxRowsPerTable: Number.MAX_SAFE_INTEGER,
+ })
+
+ mockEnv.FREE_TABLE_ROWS_LIMIT = '2500'
+ expect(getBillingDisabledTableLimits()).toEqual({
+ maxTables: 7,
+ maxRowsPerTable: 2500,
+ })
+ })
+})
diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts
index 4a275abfe54..350d8ceae8e 100644
--- a/apps/sim/lib/table/constants.ts
+++ b/apps/sim/lib/table/constants.ts
@@ -38,8 +38,9 @@ export const TABLE_LIMITS = {
/**
* Default plan-based table limits. Each value can be overridden via env vars
- * (see `getTablePlanLimits`) so self-hosted deployments can raise the free-tier
- * caps that apply when billing is disabled.
+ * (see `getTablePlanLimits`). Billing-disabled deployments are unlimited
+ * unless the free-tier env vars are explicitly set (see
+ * `getBillingDisabledTableLimits`).
*/
export const DEFAULT_TABLE_PLAN_LIMITS = {
free: {
@@ -90,6 +91,20 @@ export interface TablePlanLimits {
maxRowsPerTable: number
}
+/**
+ * Table limits for billing-disabled deployments: unlimited by default, with
+ * each cap opting back in only when its free-tier env var is explicitly set
+ * to a valid positive integer.
+ */
+export function getBillingDisabledTableLimits(): TablePlanLimits {
+ const tablesOverride = envNumber(env.FREE_TABLES_LIMIT, 0, { min: 1, integer: true })
+ const rowsOverride = envNumber(env.FREE_TABLE_ROWS_LIMIT, 0, { min: 1, integer: true })
+ return {
+ maxTables: tablesOverride > 0 ? tablesOverride : Number.MAX_SAFE_INTEGER,
+ maxRowsPerTable: rowsOverride > 0 ? rowsOverride : Number.MAX_SAFE_INTEGER,
+ }
+}
+
export type TablePlanLimitsByPlan = Record
/**
diff --git a/helm/sim/tests/env-defaults_test.yaml b/helm/sim/tests/env-defaults_test.yaml
index 7e3fdcb4898..144f45254f7 100644
--- a/helm/sim/tests/env-defaults_test.yaml
+++ b/helm/sim/tests/env-defaults_test.yaml
@@ -19,6 +19,26 @@ tests:
content:
name: BETTER_AUTH_URL
value: http://localhost:3000
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: BILLING_CONCURRENCY_LIMIT_FREE
+ value: "10"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: BILLING_CONCURRENCY_LIMIT_PRO
+ value: "50"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: BILLING_CONCURRENCY_LIMIT_TEAM
+ value: "200"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: BILLING_CONCURRENCY_LIMIT_ENTERPRISE
+ value: "1000"
- it: inline mode renders localhost envDefaults on the realtime pod
template: deployment-realtime.yaml
diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json
index 4b1e86c0a75..60bdf101dcb 100644
--- a/helm/sim/values.schema.json
+++ b/helm/sim/values.schema.json
@@ -320,6 +320,10 @@
"type": "string",
"description": "Free tier async API executions per minute"
},
+ "RATE_LIMIT_FREE_API_ENDPOINT": {
+ "type": "string",
+ "description": "Free tier v1 API endpoint requests per minute"
+ },
"RATE_LIMIT_PRO_ASYNC": {
"type": "string",
"description": "Pro tier async API executions per minute"
@@ -332,6 +336,22 @@
"type": "string",
"description": "Enterprise tier async API executions per minute"
},
+ "BILLING_CONCURRENCY_LIMIT_FREE": {
+ "type": "string",
+ "description": "In-flight executions per free billing account"
+ },
+ "BILLING_CONCURRENCY_LIMIT_PRO": {
+ "type": "string",
+ "description": "In-flight executions per Pro-tier billing account (Pro and Pro for Teams)"
+ },
+ "BILLING_CONCURRENCY_LIMIT_TEAM": {
+ "type": "string",
+ "description": "In-flight executions per Max-tier billing account (Max and Max for Teams)"
+ },
+ "BILLING_CONCURRENCY_LIMIT_ENTERPRISE": {
+ "type": "string",
+ "description": "Default in-flight executions per Enterprise billing account"
+ },
"MANUAL_EXECUTION_LIMIT": {
"type": "string",
"description": "Manual execution bypass value"
diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml
index c7a39c81e92..8361b45e034 100644
--- a/helm/sim/values.yaml
+++ b/helm/sim/values.yaml
@@ -325,6 +325,12 @@ app:
RATE_LIMIT_FREE_SYNC: "50" # Sync API executions per minute
RATE_LIMIT_FREE_ASYNC: "200" # Async API executions per minute
+ # Billing concurrency limits (in-flight executions per billing account)
+ BILLING_CONCURRENCY_LIMIT_FREE: "10"
+ BILLING_CONCURRENCY_LIMIT_PRO: "50" # Pro tier (Pro and Pro for Teams)
+ BILLING_CONCURRENCY_LIMIT_TEAM: "200" # Max tier (Max and Max for Teams)
+ BILLING_CONCURRENCY_LIMIT_ENTERPRISE: "1000" # Default; Enterprise subscription metadata can override
+
# Execution Timeouts (seconds) — sync timeouts apply to synchronous API calls
EXECUTION_TIMEOUT_FREE: "300" # Free tier sync timeout (5 minutes)
EXECUTION_TIMEOUT_PRO: "3000" # Pro tier sync timeout (50 minutes)
diff --git a/packages/db/script-migrations/0003_backfill_workspace_storage_usage.test.ts b/packages/db/script-migrations/0003_backfill_workspace_storage_usage.test.ts
deleted file mode 100644
index 6dddbd85c44..00000000000
--- a/packages/db/script-migrations/0003_backfill_workspace_storage_usage.test.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-import type { Sql } from 'postgres'
-import { describe, expect, it } from 'vitest'
-import {
- createPostgresStorageReconciliationStore,
- reconcileWorkspaceStorageAccounting,
-} from './0003_backfill_workspace_storage_usage'
-
-interface FakeWorkspace {
- id: string
- payerType: 'organization' | 'user'
- payerId: string
- sourceBytes: number
- storageUsedBytes: number
-}
-
-class FakeStorageReconciliationStore {
- readonly workspaces: FakeWorkspace[]
- readonly organizationIds: string[]
- readonly userIds: string[]
- readonly payerTotals = new Map()
- readonly payerCalls: string[] = []
- readonly workspacePages: string[][] = []
-
- constructor({
- workspaces,
- organizationIds,
- userIds,
- }: {
- workspaces: FakeWorkspace[]
- organizationIds: string[]
- userIds: string[]
- }) {
- this.workspaces = workspaces
- this.organizationIds = organizationIds
- this.userIds = userIds
- }
-
- async listWorkspaceIds(afterId: string, limit: number) {
- return this.workspaces
- .map(({ id }) => id)
- .filter((id) => id > afterId)
- .sort()
- .slice(0, limit)
- }
-
- async reconcileWorkspaces(workspaceIds: string[]) {
- this.workspacePages.push(workspaceIds)
- for (const workspace of this.workspaces.filter(({ id }) => workspaceIds.includes(id))) {
- workspace.storageUsedBytes = workspace.sourceBytes
- }
- }
-
- async listOrganizationIds(afterId: string, limit: number) {
- return this.organizationIds
- .filter((id) => id > afterId)
- .sort()
- .slice(0, limit)
- }
-
- async reconcileOrganization(organizationId: string) {
- this.payerCalls.push(`organization:${organizationId}`)
- const total = this.workspaces
- .filter(
- (workspace) =>
- workspace.payerType === 'organization' && workspace.payerId === organizationId
- )
- .reduce((sum, workspace) => sum + workspace.storageUsedBytes, 0)
- this.payerTotals.set(`organization:${organizationId}`, total)
- }
-
- async listUserIds(afterId: string, limit: number) {
- return this.userIds
- .filter((id) => id > afterId)
- .sort()
- .slice(0, limit)
- }
-
- async reconcileUser(userId: string) {
- this.payerCalls.push(`user:${userId}`)
- const total = this.workspaces
- .filter((workspace) => workspace.payerType === 'user' && workspace.payerId === userId)
- .reduce((sum, workspace) => sum + workspace.storageUsedBytes, 0)
- this.payerTotals.set(`user:${userId}`, total)
- }
-}
-
-describe('workspace storage reconciliation', () => {
- it('rebuilds workspace and payer totals in bounded keyset pages', async () => {
- const store = new FakeStorageReconciliationStore({
- workspaces: [
- {
- id: 'workspace-a',
- payerType: 'user',
- payerId: 'user-a',
- sourceBytes: 12,
- storageUsedBytes: 999,
- },
- {
- id: 'workspace-b',
- payerType: 'organization',
- payerId: 'org-a',
- sourceBytes: 20,
- storageUsedBytes: 0,
- },
- {
- id: 'workspace-c',
- payerType: 'organization',
- payerId: 'org-a',
- sourceBytes: 30,
- storageUsedBytes: 1,
- },
- ],
- organizationIds: ['org-a', 'org-empty'],
- userIds: ['user-a', 'user-empty'],
- })
-
- const result = await reconcileWorkspaceStorageAccounting(store, { batchSize: 2 })
-
- expect(result).toEqual({ workspaces: 3, organizations: 2, users: 2 })
- expect(store.workspacePages).toEqual([['workspace-a', 'workspace-b'], ['workspace-c']])
- expect(store.workspaces.map(({ storageUsedBytes }) => storageUsedBytes)).toEqual([12, 20, 30])
- expect(store.payerTotals).toEqual(
- new Map([
- ['organization:org-a', 50],
- ['organization:org-empty', 0],
- ['user:user-a', 12],
- ['user:user-empty', 0],
- ])
- )
- expect(store.payerCalls).toEqual([
- 'organization:org-a',
- 'organization:org-empty',
- 'user:user-a',
- 'user:user-empty',
- ])
- })
-
- it('is idempotent and uses each payer live workspace total', async () => {
- const store = new FakeStorageReconciliationStore({
- workspaces: [
- {
- id: 'workspace-a',
- payerType: 'user',
- payerId: 'user-a',
- sourceBytes: 42,
- storageUsedBytes: 0,
- },
- ],
- organizationIds: [],
- userIds: ['user-a'],
- })
-
- await reconcileWorkspaceStorageAccounting(store)
- store.payerTotals.set('user:user-a', 999)
- await reconcileWorkspaceStorageAccounting(store)
-
- expect(store.workspaces[0].storageUsedBytes).toBe(42)
- expect(store.payerTotals.get('user:user-a')).toBe(42)
- })
-
- it('backfills only workspace shadow ledgers during the expand phase', async () => {
- const store = new FakeStorageReconciliationStore({
- workspaces: [
- {
- id: 'workspace-a',
- payerType: 'user',
- payerId: 'user-a',
- sourceBytes: 7,
- storageUsedBytes: 0,
- },
- ],
- organizationIds: [],
- userIds: ['user-a'],
- })
- store.payerTotals.set('user:user-a', 123)
-
- const result = await reconcileWorkspaceStorageAccounting(store, {
- reconcilePayers: false,
- })
-
- expect(store.workspaces[0].storageUsedBytes).toBe(7)
- expect(store.payerTotals.get('user:user-a')).toBe(123)
- expect(store.payerCalls).toEqual([])
- expect(result).toEqual({ workspaces: 1, organizations: 0, users: 0 })
- })
-
- it('locks and reconciles one live payer at a time without a temporary snapshot', async () => {
- const queries: string[] = []
- const query = async (strings: TemplateStringsArray) => {
- const text = strings.join(' ')
- queries.push(text)
- if (text.includes('SELECT id') && text.includes('FROM organization')) {
- return [{ id: 'org-a' }]
- }
- if (text.includes('SELECT user_id AS id') && text.includes('FROM user_stats')) {
- return [{ id: 'user-a' }]
- }
- if (text.includes('coalesce(sum(storage_used_bytes)')) {
- return [{ storage_used_bytes: 42 }]
- }
- return []
- }
- const sql = Object.assign(query, {
- begin: async (callback: (tx: typeof query) => Promise) => callback(query),
- }) as unknown as Sql
- const store = createPostgresStorageReconciliationStore(sql)
-
- await store.reconcileOrganization('org-a')
- await store.reconcileUser('user-a')
-
- expect(queries.some((text) => text.includes('CREATE TEMPORARY TABLE'))).toBe(false)
- expect(queries.some((text) => text.includes('pg_advisory'))).toBe(false)
- const organizationLock = queries.findIndex(
- (text) => text.includes('FROM organization') && text.includes('FOR UPDATE')
- )
- const organizationSum = queries.findIndex(
- (text) => text.includes('FROM workspace') && text.includes('organization_id =')
- )
- const organizationUpdate = queries.findIndex((text) => text.includes('UPDATE organization'))
- expect(organizationLock).toBeGreaterThanOrEqual(0)
- expect(organizationSum).toBeGreaterThan(organizationLock)
- expect(organizationUpdate).toBeGreaterThan(organizationSum)
- expect(
- queries.some(
- (text) =>
- text.includes('FROM workspace') &&
- text.includes('organization_id IS NULL') &&
- text.includes('billed_account_user_id =')
- )
- ).toBe(true)
- })
-
- it('counts archived workspace files while excluding mothership and connector rows', async () => {
- const queries: string[] = []
- const query = async (strings: TemplateStringsArray) => {
- const text = strings.join(' ')
- queries.push(text)
- return text.includes('count(*) AS invalid_count') ? [{ invalid_count: 0 }] : []
- }
- const sql = Object.assign(query, {
- begin: async (callback: (tx: typeof query) => Promise) => callback(query),
- }) as unknown as Sql
- const store = createPostgresStorageReconciliationStore(sql)
-
- await store.reconcileWorkspaces(['workspace-a'])
-
- const workspaceFileQueries = queries.filter((text) => text.includes('FROM workspace_files'))
- const workspaceFileClauses = workspaceFileQueries.flatMap(
- (text) => text.match(/FROM workspace_files[\s\S]*?(?=UNION ALL|GROUP BY)/g) ?? []
- )
- expect(workspaceFileQueries).toHaveLength(2)
- expect(workspaceFileClauses).toHaveLength(2)
- expect(workspaceFileQueries.every((text) => text.includes("context = 'workspace'"))).toBe(true)
- expect(workspaceFileClauses.every((text) => !text.includes('deleted_at IS NULL'))).toBe(true)
- expect(queries.some((text) => text.includes('d.connector_id IS NULL'))).toBe(true)
- expect(queries.some((text) => text.includes('d.deleted_at IS NULL'))).toBe(true)
- })
-})