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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/sim/app/api/billing/portal/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { and, eq, inArray, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'

const logger = createLogger('BillingPortal')
Expand Down Expand Up @@ -45,7 +46,7 @@ export async function POST(request: NextRequest) {
and(
eq(subscriptionTable.referenceId, organizationId),
or(
eq(subscriptionTable.status, 'active'),
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
eq(subscriptionTable.cancelAtPeriodEnd, true)
)
)
Expand Down
88 changes: 2 additions & 86 deletions apps/sim/app/api/billing/route.ts
Original file line number Diff line number Diff line change
@@ -1,99 +1,15 @@
import { db } from '@sim/db'
import { member, userStats } from '@sim/db/schema'
import { member } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'

/**
* Gets the effective billing blocked status for a user.
* If user is in an org, also checks if the org owner is blocked.
*/
async function getEffectiveBillingStatus(userId: string): Promise<{
billingBlocked: boolean
billingBlockedReason: 'payment_failed' | 'dispute' | null
blockedByOrgOwner: boolean
}> {
// Check user's own status
const userStatsRows = await db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)

const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null

if (userBlocked) {
return {
billingBlocked: true,
billingBlockedReason: userBlockedReason,
blockedByOrgOwner: false,
}
}

// Check if user is in an org where owner is blocked
const memberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))

// Fetch all org owners in parallel
const ownerResults = await Promise.all(
memberships.map((m) =>
db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
.limit(1)
)
)

// Collect owner IDs that are not the current user
const otherOwnerIds = ownerResults
.filter((owners) => owners.length > 0 && owners[0].userId !== userId)
.map((owners) => owners[0].userId)

if (otherOwnerIds.length > 0) {
// Fetch all owner stats in parallel
const ownerStatsResults = await Promise.all(
otherOwnerIds.map((ownerId) =>
db
.select({
blocked: userStats.billingBlocked,
blockedReason: userStats.billingBlockedReason,
})
.from(userStats)
.where(eq(userStats.userId, ownerId))
.limit(1)
)
)

for (const stats of ownerStatsResults) {
if (stats.length > 0 && stats[0].blocked) {
return {
billingBlocked: true,
billingBlockedReason: stats[0].blockedReason,
blockedByOrgOwner: true,
}
}
}
}

return {
billingBlocked: false,
billingBlockedReason: null,
blockedByOrgOwner: false,
}
}

const logger = createLogger('UnifiedBillingAPI')

/**
Expand Down
12 changes: 11 additions & 1 deletion apps/sim/app/api/billing/switch-plan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { writeBillingInterval } from '@/lib/billing/core/subscription'
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
import { getPlanByName } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
hasUsableSubscriptionAccess,
hasUsableSubscriptionStatus,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'

const logger = createLogger('SwitchPlan')
Expand Down Expand Up @@ -60,6 +65,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
}

const billingStatus = await getEffectiveBillingStatus(userId)
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) {
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
}

if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
return NextResponse.json(
{ error: 'Enterprise plan changes must be handled via support' },
Expand Down Expand Up @@ -91,7 +101,7 @@ export async function POST(request: NextRequest) {
const stripe = requireStripeClient()
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)

if (stripeSubscription.status !== 'active') {
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}

Expand Down
12 changes: 9 additions & 3 deletions apps/sim/app/api/logs/cleanup/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { db } from '@sim/db'
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import { and, eq, inArray, isNull, lt } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { sqlIsPaid } from '@/lib/billing/plan-helpers'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/core/config/env'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
Expand All @@ -29,9 +31,13 @@ export async function GET(request: NextRequest) {
.from(user)
.leftJoin(
subscription,
sql`${user.id} = ${subscription.referenceId} AND ${subscription.status} = 'active' AND ${subscription.plan} IN ('pro', 'team', 'enterprise')`
and(
eq(user.id, subscription.referenceId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
sqlIsPaid(subscription.plan)
)
)
.where(sql`${subscription.id} IS NULL`)
.where(isNull(subscription.id))

if (freeUsers.length === 0) {
logger.info('No free users found for log cleanup')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ import {
workspaceInvitation,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { sendEmail } from '@/lib/messaging/email/mailer'
Expand Down Expand Up @@ -320,7 +321,7 @@ export async function PUT(
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
eq(subscriptionTable.status, 'active')
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
Expand All @@ -338,8 +339,8 @@ export async function PUT(
.where(
and(
eq(subscriptionTable.referenceId, userId),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
sqlIsPro(subscriptionTable.plan)
)
)
.limit(1)
Expand Down
20 changes: 17 additions & 3 deletions apps/sim/app/api/organizations/[id]/seats/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getPlanPricing } from '@/lib/billing/core/billing'
import { isTeam } from '@/lib/billing/plan-helpers'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
hasUsableSubscriptionStatus,
USABLE_SUBSCRIPTION_STATUSES,
} from '@/lib/billing/subscriptions/utils'
import { isBillingEnabled } from '@/lib/core/config/feature-flags'

const logger = createLogger('OrganizationSeatsAPI')
Expand Down Expand Up @@ -66,7 +71,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const subscriptionRecord = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)

if (subscriptionRecord.length === 0) {
Expand All @@ -75,6 +85,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{

const orgSubscription = subscriptionRecord[0]

if (await isOrganizationBillingBlocked(organizationId)) {
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
}

// Only team plans support seat changes (not enterprise - those are handled manually)
if (!isTeam(orgSubscription.plan)) {
return NextResponse.json(
Expand Down Expand Up @@ -127,7 +141,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
orgSubscription.stripeSubscriptionId
)

if (stripeSubscription.status !== 'active') {
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
}

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { hasActiveSubscription } from '@/lib/billing'
import { hasPaidSubscription } from '@/lib/billing'

const logger = createLogger('SubscriptionTransferAPI')

Expand Down Expand Up @@ -90,7 +90,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}

// Check if org already has an active subscription (prevent duplicates)
if (await hasActiveSubscription(organizationId)) {
if (await hasPaidSubscription(organizationId)) {
return NextResponse.json(
{ error: 'Organization already has an active subscription' },
{ status: 409 }
Expand Down
19 changes: 14 additions & 5 deletions apps/sim/app/api/v1/admin/credits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@
import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getEffectiveSeats,
} from '@/lib/billing/subscriptions/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
Expand Down Expand Up @@ -95,7 +99,7 @@ export const POST = withAdminAuth(async (request) => {

const userSubscription = await getHighestPrioritySubscription(resolvedUserId)

if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
if (!userSubscription || !isPaid(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
Expand All @@ -106,7 +110,7 @@ export const POST = withAdminAuth(async (request) => {
const plan = userSubscription.plan
let seats: number | null = null

if (plan === 'team' || plan === 'enterprise') {
if (isOrgPlan(plan)) {
entityType = 'organization'
entityId = userSubscription.referenceId

Expand All @@ -123,7 +127,12 @@ export const POST = withAdminAuth(async (request) => {
const [subData] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
.where(
and(
eq(subscription.referenceId, entityId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)

seats = getEffectiveSeats(subData)
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/app/api/v1/admin/organizations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq } from 'drizzle-orm'
import { and, count, eq, inArray } from 'drizzle-orm'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
Expand Down Expand Up @@ -58,7 +59,12 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1),
])

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/v1/admin/users/[id]/billing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger'
import { eq, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isOrgPlan } from '@/lib/billing/plan-helpers'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
Expand Down Expand Up @@ -154,8 +155,7 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
.limit(1)

const userSubscription = await getHighestPrioritySubscription(userId)
const isTeamOrEnterpriseMember =
userSubscription && ['team', 'enterprise'].includes(userSubscription.plan)
const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan)

const [orgMembership] = await db
.select({ organizationId: member.organizationId })
Expand Down
Loading
Loading