Skip to content

Commit 8800f03

Browse files
improvement(billing): treat past_due state correctly (#3750)
* improvement(billing): treat past_due state correctly * toggle fixes * address comments * Fix type errors
1 parent b497033 commit 8800f03

File tree

43 files changed

+597
-242
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+597
-242
lines changed

apps/sim/app/api/billing/portal/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { db } from '@sim/db'
22
import { subscription as subscriptionTable, user } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq, or } from 'drizzle-orm'
4+
import { and, eq, inArray, or } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
77
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
88
import { requireStripeClient } from '@/lib/billing/stripe-client'
9+
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
910
import { getBaseUrl } from '@/lib/core/utils/urls'
1011

1112
const logger = createLogger('BillingPortal')
@@ -45,7 +46,7 @@ export async function POST(request: NextRequest) {
4546
and(
4647
eq(subscriptionTable.referenceId, organizationId),
4748
or(
48-
eq(subscriptionTable.status, 'active'),
49+
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
4950
eq(subscriptionTable.cancelAtPeriodEnd, true)
5051
)
5152
)

apps/sim/app/api/billing/route.ts

Lines changed: 2 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,15 @@
11
import { db } from '@sim/db'
2-
import { member, userStats } from '@sim/db/schema'
2+
import { member } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
7+
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
78
import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
89
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
910
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
1011
import { getPlanTierCredits } from '@/lib/billing/plan-helpers'
1112

12-
/**
13-
* Gets the effective billing blocked status for a user.
14-
* If user is in an org, also checks if the org owner is blocked.
15-
*/
16-
async function getEffectiveBillingStatus(userId: string): Promise<{
17-
billingBlocked: boolean
18-
billingBlockedReason: 'payment_failed' | 'dispute' | null
19-
blockedByOrgOwner: boolean
20-
}> {
21-
// Check user's own status
22-
const userStatsRows = await db
23-
.select({
24-
blocked: userStats.billingBlocked,
25-
blockedReason: userStats.billingBlockedReason,
26-
})
27-
.from(userStats)
28-
.where(eq(userStats.userId, userId))
29-
.limit(1)
30-
31-
const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
32-
const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null
33-
34-
if (userBlocked) {
35-
return {
36-
billingBlocked: true,
37-
billingBlockedReason: userBlockedReason,
38-
blockedByOrgOwner: false,
39-
}
40-
}
41-
42-
// Check if user is in an org where owner is blocked
43-
const memberships = await db
44-
.select({ organizationId: member.organizationId })
45-
.from(member)
46-
.where(eq(member.userId, userId))
47-
48-
// Fetch all org owners in parallel
49-
const ownerResults = await Promise.all(
50-
memberships.map((m) =>
51-
db
52-
.select({ userId: member.userId })
53-
.from(member)
54-
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
55-
.limit(1)
56-
)
57-
)
58-
59-
// Collect owner IDs that are not the current user
60-
const otherOwnerIds = ownerResults
61-
.filter((owners) => owners.length > 0 && owners[0].userId !== userId)
62-
.map((owners) => owners[0].userId)
63-
64-
if (otherOwnerIds.length > 0) {
65-
// Fetch all owner stats in parallel
66-
const ownerStatsResults = await Promise.all(
67-
otherOwnerIds.map((ownerId) =>
68-
db
69-
.select({
70-
blocked: userStats.billingBlocked,
71-
blockedReason: userStats.billingBlockedReason,
72-
})
73-
.from(userStats)
74-
.where(eq(userStats.userId, ownerId))
75-
.limit(1)
76-
)
77-
)
78-
79-
for (const stats of ownerStatsResults) {
80-
if (stats.length > 0 && stats[0].blocked) {
81-
return {
82-
billingBlocked: true,
83-
billingBlockedReason: stats[0].blockedReason,
84-
blockedByOrgOwner: true,
85-
}
86-
}
87-
}
88-
}
89-
90-
return {
91-
billingBlocked: false,
92-
billingBlockedReason: null,
93-
blockedByOrgOwner: false,
94-
}
95-
}
96-
9713
const logger = createLogger('UnifiedBillingAPI')
9814

9915
/**

apps/sim/app/api/billing/switch-plan/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
8+
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
89
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
910
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
1011
import { writeBillingInterval } from '@/lib/billing/core/subscription'
1112
import { getPlanType, isEnterprise, isOrgPlan } from '@/lib/billing/plan-helpers'
1213
import { getPlanByName } from '@/lib/billing/plans'
1314
import { requireStripeClient } from '@/lib/billing/stripe-client'
15+
import {
16+
hasUsableSubscriptionAccess,
17+
hasUsableSubscriptionStatus,
18+
} from '@/lib/billing/subscriptions/utils'
1419
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
1520

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

68+
const billingStatus = await getEffectiveBillingStatus(userId)
69+
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) {
70+
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
71+
}
72+
6373
if (isEnterprise(sub.plan) || isEnterprise(targetPlanName)) {
6474
return NextResponse.json(
6575
{ error: 'Enterprise plan changes must be handled via support' },
@@ -91,7 +101,7 @@ export async function POST(request: NextRequest) {
91101
const stripe = requireStripeClient()
92102
const stripeSubscription = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId)
93103

94-
if (stripeSubscription.status !== 'active') {
104+
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
95105
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
96106
}
97107

apps/sim/app/api/logs/cleanup/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { db } from '@sim/db'
22
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
4+
import { and, eq, inArray, isNull, lt } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { verifyCronAuth } from '@/lib/auth/internal'
7+
import { sqlIsPaid } from '@/lib/billing/plan-helpers'
8+
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
79
import { env } from '@/lib/core/config/env'
810
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
911
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
@@ -29,9 +31,13 @@ export async function GET(request: NextRequest) {
2931
.from(user)
3032
.leftJoin(
3133
subscription,
32-
sql`${user.id} = ${subscription.referenceId} AND ${subscription.status} = 'active' AND ${subscription.plan} IN ('pro', 'team', 'enterprise')`
34+
and(
35+
eq(user.id, subscription.referenceId),
36+
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES),
37+
sqlIsPaid(subscription.plan)
38+
)
3339
)
34-
.where(sql`${subscription.id} IS NULL`)
40+
.where(isNull(subscription.id))
3541

3642
if (freeUsers.length === 0) {
3743
logger.info('No free users found for log cleanup')

apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,17 @@ import {
1515
workspaceInvitation,
1616
} from '@sim/db/schema'
1717
import { createLogger } from '@sim/logger'
18-
import { and, eq } from 'drizzle-orm'
18+
import { and, eq, inArray } from 'drizzle-orm'
1919
import { type NextRequest, NextResponse } from 'next/server'
2020
import { z } from 'zod'
2121
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
2222
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
2323
import { getSession } from '@/lib/auth'
2424
import { hasAccessControlAccess } from '@/lib/billing'
2525
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
26-
import { isOrgPlan } from '@/lib/billing/plan-helpers'
26+
import { isOrgPlan, sqlIsPro } from '@/lib/billing/plan-helpers'
2727
import { requireStripeClient } from '@/lib/billing/stripe-client'
28+
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
2829
import { getBaseUrl } from '@/lib/core/utils/urls'
2930
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
3031
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -320,7 +321,7 @@ export async function PUT(
320321
.where(
321322
and(
322323
eq(subscriptionTable.referenceId, organizationId),
323-
eq(subscriptionTable.status, 'active')
324+
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
324325
)
325326
)
326327
.limit(1)
@@ -338,8 +339,8 @@ export async function PUT(
338339
.where(
339340
and(
340341
eq(subscriptionTable.referenceId, userId),
341-
eq(subscriptionTable.status, 'active'),
342-
eq(subscriptionTable.plan, 'pro')
342+
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
343+
sqlIsPro(subscriptionTable.plan)
343344
)
344345
)
345346
.limit(1)

apps/sim/app/api/organizations/[id]/seats/route.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { db } from '@sim/db'
22
import { member, organization, subscription } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
4+
import { and, eq, inArray } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
8+
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
89
import { getPlanPricing } from '@/lib/billing/core/billing'
910
import { isTeam } from '@/lib/billing/plan-helpers'
1011
import { requireStripeClient } from '@/lib/billing/stripe-client'
12+
import {
13+
hasUsableSubscriptionStatus,
14+
USABLE_SUBSCRIPTION_STATUSES,
15+
} from '@/lib/billing/subscriptions/utils'
1116
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
1217

1318
const logger = createLogger('OrganizationSeatsAPI')
@@ -66,7 +71,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
6671
const subscriptionRecord = await db
6772
.select()
6873
.from(subscription)
69-
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
74+
.where(
75+
and(
76+
eq(subscription.referenceId, organizationId),
77+
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
78+
)
79+
)
7080
.limit(1)
7181

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

7686
const orgSubscription = subscriptionRecord[0]
7787

88+
if (await isOrganizationBillingBlocked(organizationId)) {
89+
return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
90+
}
91+
7892
// Only team plans support seat changes (not enterprise - those are handled manually)
7993
if (!isTeam(orgSubscription.plan)) {
8094
return NextResponse.json(
@@ -127,7 +141,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
127141
orgSubscription.stripeSubscriptionId
128142
)
129143

130-
if (stripeSubscription.status !== 'active') {
144+
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
131145
return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
132146
}
133147

apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
8-
import { hasActiveSubscription } from '@/lib/billing'
8+
import { hasPaidSubscription } from '@/lib/billing'
99

1010
const logger = createLogger('SubscriptionTransferAPI')
1111

@@ -90,7 +90,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
9090
}
9191

9292
// Check if org already has an active subscription (prevent duplicates)
93-
if (await hasActiveSubscription(organizationId)) {
93+
if (await hasPaidSubscription(organizationId)) {
9494
return NextResponse.json(
9595
{ error: 'Organization already has an active subscription' },
9696
{ status: 409 }

apps/sim/app/api/v1/admin/credits/route.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,16 @@
2626
import { db } from '@sim/db'
2727
import { organization, subscription, user, userStats } from '@sim/db/schema'
2828
import { createLogger } from '@sim/logger'
29-
import { and, eq } from 'drizzle-orm'
29+
import { and, eq, inArray } from 'drizzle-orm'
3030
import { nanoid } from 'nanoid'
3131
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
3232
import { addCredits } from '@/lib/billing/credits/balance'
3333
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
34-
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
34+
import { isOrgPlan, isPaid } from '@/lib/billing/plan-helpers'
35+
import {
36+
ENTITLED_SUBSCRIPTION_STATUSES,
37+
getEffectiveSeats,
38+
} from '@/lib/billing/subscriptions/utils'
3539
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
3640
import {
3741
badRequestResponse,
@@ -95,7 +99,7 @@ export const POST = withAdminAuth(async (request) => {
9599

96100
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
97101

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

109-
if (plan === 'team' || plan === 'enterprise') {
113+
if (isOrgPlan(plan)) {
110114
entityType = 'organization'
111115
entityId = userSubscription.referenceId
112116

@@ -123,7 +127,12 @@ export const POST = withAdminAuth(async (request) => {
123127
const [subData] = await db
124128
.select()
125129
.from(subscription)
126-
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
130+
.where(
131+
and(
132+
eq(subscription.referenceId, entityId),
133+
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
134+
)
135+
)
127136
.limit(1)
128137

129138
seats = getEffectiveSeats(subData)

apps/sim/app/api/v1/admin/organizations/[id]/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
import { db } from '@sim/db'
2020
import { member, organization, subscription } from '@sim/db/schema'
2121
import { createLogger } from '@sim/logger'
22-
import { and, count, eq } from 'drizzle-orm'
22+
import { and, count, eq, inArray } from 'drizzle-orm'
23+
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
2324
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
2425
import {
2526
badRequestResponse,
@@ -58,7 +59,12 @@ export const GET = withAdminAuthParams<RouteParams>(async (request, context) =>
5859
db
5960
.select()
6061
.from(subscription)
61-
.where(and(eq(subscription.referenceId, organizationId), eq(subscription.status, 'active')))
62+
.where(
63+
and(
64+
eq(subscription.referenceId, organizationId),
65+
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
66+
)
67+
)
6268
.limit(1),
6369
])
6470

apps/sim/app/api/v1/admin/users/[id]/billing/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger'
2424
import { eq, or } from 'drizzle-orm'
2525
import { nanoid } from 'nanoid'
2626
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
27+
import { isOrgPlan } from '@/lib/billing/plan-helpers'
2728
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
2829
import {
2930
badRequestResponse,
@@ -154,8 +155,7 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
154155
.limit(1)
155156

156157
const userSubscription = await getHighestPrioritySubscription(userId)
157-
const isTeamOrEnterpriseMember =
158-
userSubscription && ['team', 'enterprise'].includes(userSubscription.plan)
158+
const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan)
159159

160160
const [orgMembership] = await db
161161
.select({ organizationId: member.organizationId })

0 commit comments

Comments
 (0)