diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index 34807f0c6d..7c42728f72 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -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') @@ -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) ) ) diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 1d79324305..3fbae3c1df 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -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') /** diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index bf984a075a..b668335db2 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -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') @@ -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' }, @@ -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 }) } diff --git a/apps/sim/app/api/logs/cleanup/route.ts b/apps/sim/app/api/logs/cleanup/route.ts index 2293199c6b..25a0acabf5 100644 --- a/apps/sim/app/api/logs/cleanup/route.ts +++ b/apps/sim/app/api/logs/cleanup/route.ts @@ -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' @@ -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') diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index d7b1df2a77..044f239d82 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -15,7 +15,7 @@ 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' @@ -23,8 +23,9 @@ 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' @@ -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) @@ -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) diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 0c3535df3f..6a2be6238c 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -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') @@ -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) { @@ -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( @@ -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 }) } diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts index 16a0023edf..fad7ddb9a8 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts @@ -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') @@ -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 } diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index e2bdfbe079..93276f8afc 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -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, @@ -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' ) @@ -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 @@ -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) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 3d0373014e..5542d8b313 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -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, @@ -58,7 +59,12 @@ export const GET = withAdminAuthParams(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), ]) diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 0caf2c655a..5e863c82ab 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -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, @@ -154,8 +155,7 @@ export const PATCH = withAdminAuthParams(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 }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 085884488e..b382e284be 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -8,8 +8,10 @@ import { db } from '@sim/db' import { member, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { getEffectiveBillingStatus } from '@/lib/billing/core/access' +import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' const logger = createLogger('V1AuditLogsAuth') @@ -57,6 +59,17 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise= 25000 || isEnterprise(plan) + const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data) if (isLoading || (isBillingEnabled && isSubLoading)) { return } - if (isBillingEnabled && !isMaxPlan) { + if (isBillingEnabled && !subscriptionAccess.hasUsableMaxAccess) { return (

- Sim Mailer requires a Max plan + Sim Mailer requires an active Max plan

- Upgrade to Max to receive tasks via email and let Sim work on your behalf. + Upgrade to Max and ensure billing is active to receive tasks via email and let Sim work + on your behalf.