Skip to content

Commit 442eaba

Browse files
improvement(checkout): enforce team/enterprise-only org subscription, block double-covered personal checkouts (#5715)
* improvement(checkout): enforce team/enterprise-only org subscriptions, block double-covered personal checkouts, and drop renewal-triggered workspace detach * close race with personal pro / org inclusions * address comments * fix(billing): compute org coverage independently of the personal-sub lookup and fail closed on unverifiable plan writes
1 parent 9d5ed38 commit 442eaba

20 files changed

Lines changed: 1361 additions & 167 deletions

apps/sim/lib/auth/auth.ts

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import * as schema from '@sim/db/schema'
88
import { createLogger } from '@sim/logger'
99
import { toError } from '@sim/utils/errors'
1010
import { generateId } from '@sim/utils/id'
11-
import { betterAuth, type User } from 'better-auth'
11+
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
1212
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
13-
import { APIError, createAuthMiddleware } from 'better-auth/api'
13+
import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
1414
import { nextCookies } from 'better-auth/next-js'
1515
import {
1616
admin,
@@ -34,8 +34,13 @@ import {
3434
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
3535
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
3636
import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants'
37+
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
3738
import { sendPlanWelcomeEmail } from '@/lib/billing'
38-
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
39+
import {
40+
assertPersonalCheckoutAllowed,
41+
authorizeSubscriptionReference,
42+
isPersonalCheckoutRequest,
43+
} from '@/lib/billing/authorization'
3944
import {
4045
getOrganizationIdForSubscriptionReference,
4146
syncSubscriptionPlan,
@@ -46,7 +51,8 @@ import {
4651
ensureOrganizationForTeamSubscription,
4752
syncSubscriptionUsageLimits,
4853
} from '@/lib/billing/organization'
49-
import { isTeam } from '@/lib/billing/plan-helpers'
54+
import { pauseProSubscriptionForOrgCoverage } from '@/lib/billing/organizations/membership'
55+
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
5056
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
5157
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
5258
import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout'
@@ -58,7 +64,6 @@ import {
5864
handleInvoicePaymentSucceeded,
5965
} from '@/lib/billing/webhooks/invoices'
6066
import {
61-
handleOrganizationPlanDowngrade,
6267
handleSubscriptionCreated,
6368
handleSubscriptionDeleted,
6469
} from '@/lib/billing/webhooks/subscription'
@@ -193,10 +198,13 @@ export const auth = betterAuth({
193198
...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []),
194199
...additionalTrustedOrigins,
195200
].filter(Boolean),
196-
database: drizzleAdapter(db, {
197-
provider: 'pg',
198-
schema,
199-
}),
201+
database: (options: BetterAuthOptions) =>
202+
guardSubscriptionPlanWrites(
203+
drizzleAdapter(db, {
204+
provider: 'pg',
205+
schema,
206+
})(options)
207+
),
200208
session: {
201209
cookieCache: {
202210
enabled: true,
@@ -903,6 +911,21 @@ export const auth = betterAuth({
903911
}
904912
}
905913

914+
/**
915+
* Personal checkout guard. The Stripe plugin's `authorizeReference`
916+
* only runs for organization references (it skips references equal to
917+
* the session user), so duplicate-coverage enforcement for personal
918+
* checkouts lives here: a member of an org with an entitled paid
919+
* subscription must not buy a personal plan on top of it.
920+
*/
921+
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
922+
const session = await getSessionFromCtx(ctx)
923+
const sessionUserId = session?.user?.id
924+
if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) {
925+
await assertPersonalCheckoutAllowed(sessionUserId)
926+
}
927+
}
928+
906929
return
907930
}),
908931
},
@@ -3140,8 +3163,21 @@ export const auth = betterAuth({
31403163
subscription: {
31413164
enabled: true,
31423165
plans: getPlans(),
3143-
authorizeReference: async ({ user, referenceId, action }) => {
3144-
return await authorizeSubscriptionReference(user.id, referenceId, action)
3166+
authorizeReference: async ({ user, referenceId, action }, ctx) => {
3167+
const body: unknown = ctx?.body
3168+
const requestedPlan =
3169+
typeof body === 'object' &&
3170+
body !== null &&
3171+
'plan' in body &&
3172+
typeof body.plan === 'string'
3173+
? body.plan
3174+
: undefined
3175+
return await authorizeSubscriptionReference(
3176+
user.id,
3177+
referenceId,
3178+
action,
3179+
requestedPlan
3180+
)
31453181
},
31463182
getCheckoutSessionParams: async () => ({
31473183
params: { allow_promotion_codes: true },
@@ -3175,11 +3211,16 @@ export const auth = betterAuth({
31753211
)
31763212
}
31773213

3178-
await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe)
3214+
const syncedPlan = await syncSubscriptionPlan(
3215+
subscription.id,
3216+
subscription.plan,
3217+
planFromStripe,
3218+
subscription.referenceId
3219+
)
31793220

31803221
const subscriptionForOrg = {
31813222
...subscription,
3182-
plan: planFromStripe ?? subscription.plan,
3223+
plan: syncedPlan ?? subscription.plan,
31833224
enterpriseOperationId: stripeSubscription.metadata?.enterpriseOperationId ?? null,
31843225
}
31853226

@@ -3202,7 +3243,29 @@ export const auth = betterAuth({
32023243
throw orgError
32033244
}
32043245

3205-
await handleSubscriptionCreated(resolvedSubscription, event.id)
3246+
/**
3247+
* Transactional fence behind the personal-checkout admission
3248+
* guard: if the user joined a paid organization while their
3249+
* checkout was in flight, pause the fresh personal Pro at
3250+
* period end (same state a paid-org joiner's personal Pro
3251+
* enters; restored automatically if they leave the org).
3252+
*
3253+
* Runs BEFORE the free→paid transition handling: a personal
3254+
* subscription born covered is not a free→paid transition —
3255+
* the org plan keeps governing the user — so the usage reset
3256+
* (which would wipe org-attributed current-period usage) and
3257+
* its instrumentation must not run. Gated on `covered`, not
3258+
* `paused`, so event retries decide identically even when the
3259+
* join path already paused the subscription.
3260+
*/
3261+
const coveredByOrganization = isPro(resolvedSubscription.plan)
3262+
? (await pauseProSubscriptionForOrgCoverage(resolvedSubscription.referenceId))
3263+
.covered
3264+
: false
3265+
3266+
if (!coveredByOrganization) {
3267+
await handleSubscriptionCreated(resolvedSubscription, event.id)
3268+
}
32063269

32073270
await syncSubscriptionUsageLimits(resolvedSubscription)
32083271

@@ -3238,8 +3301,6 @@ export const auth = betterAuth({
32383301
const isUpgradeToTeam =
32393302
isTeamPlan && !isTeam(subscription.plan) && referenceOrganizationId == null
32403303

3241-
const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan
3242-
32433304
logger.info('[onSubscriptionUpdate] Subscription updated', {
32443305
subscriptionId: subscription.id,
32453306
status: subscription.status,
@@ -3258,11 +3319,24 @@ export const auth = betterAuth({
32583319
)
32593320
}
32603321

3261-
await syncSubscriptionPlan(subscription.id, subscription.plan, planFromStripe)
3322+
const syncedPlan = await syncSubscriptionPlan(
3323+
subscription.id,
3324+
subscription.plan,
3325+
planFromStripe,
3326+
subscription.referenceId
3327+
)
3328+
3329+
/**
3330+
* All downstream processing keys off the plan the DB actually
3331+
* holds after the sync — a plan write refused by the org/plan
3332+
* invariant must not leak the rejected Stripe plan into org
3333+
* resolution, seat sync, or usage limits.
3334+
*/
3335+
const effectivePlanForTeamFeatures = syncedPlan ?? subscription.plan
32623336

32633337
const subscriptionForOrg = {
32643338
...subscription,
3265-
plan: planFromStripe ?? subscription.plan,
3339+
plan: effectivePlanForTeamFeatures,
32663340
enterpriseOperationId: stripeSubscription.metadata?.enterpriseOperationId ?? null,
32673341
}
32683342

@@ -3333,16 +3407,6 @@ export const auth = betterAuth({
33333407
error,
33343408
})
33353409
}
3336-
} else {
3337-
await handleOrganizationPlanDowngrade(
3338-
{
3339-
id: resolvedSubscription.id,
3340-
plan: effectivePlanForTeamFeatures ?? null,
3341-
referenceId: resolvedSubscription.referenceId,
3342-
status: resolvedSubscription.status ?? null,
3343-
},
3344-
event.id
3345-
)
33463410
}
33473411

33483412
await writeBillingInterval(resolvedSubscription.id, isAnnual ? 'year' : 'month')
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@sim/db', () => dbChainMock)
8+
9+
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
10+
11+
function createBaseAdapter() {
12+
return {
13+
create: vi.fn(async (data: { data: unknown }) => data.data),
14+
update: vi.fn(async (data: { update: unknown }) => data.update),
15+
updateMany: vi.fn(async () => 1),
16+
findOne: vi.fn(),
17+
}
18+
}
19+
20+
/**
21+
* The guard only relies on create/update/updateMany/findOne; the remaining
22+
* adapter surface passes through the spread untouched.
23+
*/
24+
// double-cast-allowed: test double implements only the adapter subset the guard touches
25+
const asAdapter = (base: ReturnType<typeof createBaseAdapter>) =>
26+
guardSubscriptionPlanWrites(base as unknown as Parameters<typeof guardSubscriptionPlanWrites>[0])
27+
28+
const ORG_ROW = { id: 'sub-1', referenceId: 'org-1', plan: 'team_6000' }
29+
const WHERE = [{ field: 'id', value: 'sub-1' }]
30+
31+
describe('guardSubscriptionPlanWrites', () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
resetDbChainMock()
35+
})
36+
37+
it('strips a non-org plan from an update targeting an org-referenced row but keeps the rest', async () => {
38+
const base = createBaseAdapter()
39+
base.findOne.mockResolvedValueOnce(ORG_ROW)
40+
// org existence lookup
41+
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
42+
43+
const guarded = asAdapter(base)
44+
await guarded.update({
45+
model: 'subscription',
46+
where: WHERE as never,
47+
update: { plan: 'pro_6000', status: 'active', seats: 2 },
48+
})
49+
50+
expect(base.update).toHaveBeenCalledWith(
51+
expect.objectContaining({ update: { status: 'active', seats: 2 } })
52+
)
53+
})
54+
55+
it('returns the current row without writing when stripping leaves an empty update', async () => {
56+
const base = createBaseAdapter()
57+
base.findOne.mockResolvedValueOnce(ORG_ROW).mockResolvedValueOnce(ORG_ROW)
58+
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
59+
60+
const guarded = asAdapter(base)
61+
const result = await guarded.update({
62+
model: 'subscription',
63+
where: WHERE as never,
64+
update: { plan: 'pro_6000' },
65+
})
66+
67+
expect(result).toEqual(ORG_ROW)
68+
expect(base.update).not.toHaveBeenCalled()
69+
})
70+
71+
it('strips the plan when the pre-update lookup finds no row (fail closed)', async () => {
72+
const base = createBaseAdapter()
73+
base.findOne.mockResolvedValueOnce(null)
74+
75+
const guarded = asAdapter(base)
76+
await guarded.update({
77+
model: 'subscription',
78+
where: WHERE as never,
79+
update: { plan: 'pro_6000', status: 'active' },
80+
})
81+
82+
expect(base.update).toHaveBeenCalledWith(
83+
expect.objectContaining({ update: { status: 'active' } })
84+
)
85+
})
86+
87+
it('writes nothing when a plan-only update targets no readable row', async () => {
88+
const base = createBaseAdapter()
89+
base.findOne.mockResolvedValueOnce(null)
90+
91+
const guarded = asAdapter(base)
92+
const result = await guarded.update({
93+
model: 'subscription',
94+
where: WHERE as never,
95+
update: { plan: 'pro_6000' },
96+
})
97+
98+
expect(result).toBeNull()
99+
expect(base.update).not.toHaveBeenCalled()
100+
})
101+
102+
it('passes through non-org plan updates for user-referenced rows', async () => {
103+
const base = createBaseAdapter()
104+
base.findOne.mockResolvedValueOnce({ id: 'sub-2', referenceId: 'user-1', plan: 'pro_6000' })
105+
// org existence lookup: user id is not an organization
106+
dbChainMockFns.limit.mockResolvedValueOnce([])
107+
108+
const guarded = asAdapter(base)
109+
await guarded.update({
110+
model: 'subscription',
111+
where: WHERE as never,
112+
update: { plan: 'pro_25000', status: 'active' },
113+
})
114+
115+
expect(base.update).toHaveBeenCalledWith(
116+
expect.objectContaining({ update: { plan: 'pro_25000', status: 'active' } })
117+
)
118+
})
119+
120+
it('passes through org-plan updates without any row lookup', async () => {
121+
const base = createBaseAdapter()
122+
123+
const guarded = asAdapter(base)
124+
await guarded.update({
125+
model: 'subscription',
126+
where: WHERE as never,
127+
update: { plan: 'team_25000', seats: 5 },
128+
})
129+
130+
expect(base.findOne).not.toHaveBeenCalled()
131+
expect(base.update).toHaveBeenCalledWith(
132+
expect.objectContaining({ update: { plan: 'team_25000', seats: 5 } })
133+
)
134+
})
135+
136+
it('passes through updates on other models untouched', async () => {
137+
const base = createBaseAdapter()
138+
139+
const guarded = asAdapter(base)
140+
await guarded.update({
141+
model: 'user',
142+
where: WHERE as never,
143+
update: { plan: 'pro_6000' },
144+
})
145+
146+
expect(base.findOne).not.toHaveBeenCalled()
147+
expect(base.update).toHaveBeenCalled()
148+
})
149+
150+
it('rejects creating an org-referenced subscription with a non-org plan', async () => {
151+
const base = createBaseAdapter()
152+
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
153+
154+
const guarded = asAdapter(base)
155+
await expect(
156+
guarded.create({
157+
model: 'subscription',
158+
data: { plan: 'pro_6000', referenceId: 'org-1' } as never,
159+
})
160+
).rejects.toThrow(/must hold a Team or Enterprise plan/)
161+
162+
expect(base.create).not.toHaveBeenCalled()
163+
})
164+
165+
it('allows creating a personal pro subscription', async () => {
166+
const base = createBaseAdapter()
167+
dbChainMockFns.limit.mockResolvedValueOnce([])
168+
169+
const guarded = asAdapter(base)
170+
await guarded.create({
171+
model: 'subscription',
172+
data: { plan: 'pro_6000', referenceId: 'user-1' } as never,
173+
})
174+
175+
expect(base.create).toHaveBeenCalled()
176+
})
177+
})

0 commit comments

Comments
 (0)