Skip to content

Commit 9f8d4d1

Browse files
fix(billing): checkout guard, admin panel case (#6641)
* fix(billing): checkout guard, admin panel case * fix(billing): serialize checkout admission * fix(billing): release checkout admission claim
1 parent e56b288 commit 9f8d4d1

10 files changed

Lines changed: 704 additions & 20 deletions

File tree

apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ describe('useUpgradeState', () => {
120120
})
121121
})
122122

123+
it('shows checkout admission failures through the standard error toast', async () => {
124+
mockHandleUpgrade.mockRejectedValueOnce(
125+
new Error('Your subscription payment is still processing.')
126+
)
127+
128+
await act(async () => {
129+
root.render(<Harness />)
130+
})
131+
132+
await act(async () => {
133+
await currentState?.doUpgrade('team', 25000)
134+
})
135+
136+
expect(mockToastError).toHaveBeenCalledWith('Your subscription payment is still processing.')
137+
})
138+
123139
it('includes the routed workspace when switching the host billing interval', async () => {
124140
await act(async () => {
125141
root.render(<Harness />)

apps/sim/lib/auth/auth.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ import {
3838
} from '@/lib/auth/constants'
3939
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
4040
import { clampExpiryForSession } from '@/lib/auth/session-policy'
41+
import { getActiveOrganizationId } from '@/lib/auth/session-response'
4142
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
4243
import { sendPlanWelcomeEmail } from '@/lib/billing'
4344
import {
4445
assertPersonalCheckoutAllowed,
4546
authorizeSubscriptionReference,
4647
isPersonalCheckoutRequest,
4748
} from '@/lib/billing/authorization'
49+
import {
50+
type CheckoutAdmissionClaim,
51+
claimCheckoutAdmission,
52+
releaseCheckoutAdmission,
53+
resolveCheckoutReferenceId,
54+
} from '@/lib/billing/checkout-admission'
4855
import {
4956
getOrganizationIdForSubscriptionReference,
5057
syncSubscriptionPlan,
@@ -1001,20 +1008,46 @@ export const auth = betterAuth({
10011008
/**
10021009
* Personal checkout guard. The Stripe plugin's `authorizeReference`
10031010
* only runs for organization references (it skips references equal to
1004-
* the session user), so duplicate-coverage enforcement for personal
1005-
* checkouts lives here: a member of an org with an entitled paid
1006-
* subscription must not buy a personal plan on top of it.
1011+
* the session user), so personal checkout admission lives here. It
1012+
* prevents both a duplicate checkout while Stripe payment is pending
1013+
* and a personal plan for someone already covered by an organization.
10071014
*/
10081015
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
10091016
const session = await getSessionFromCtx(ctx)
10101017
const sessionUserId = session?.user?.id
1011-
if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) {
1012-
await assertPersonalCheckoutAllowed(sessionUserId)
1018+
if (sessionUserId) {
1019+
const requestBody = ctx.body ?? {}
1020+
const referenceId = resolveCheckoutReferenceId(
1021+
requestBody,
1022+
sessionUserId,
1023+
getActiveOrganizationId(session)
1024+
)
1025+
if (referenceId) {
1026+
const checkoutAdmissionClaim = await claimCheckoutAdmission(referenceId)
1027+
try {
1028+
if (isPersonalCheckoutRequest(requestBody, sessionUserId)) {
1029+
await assertPersonalCheckoutAllowed(sessionUserId)
1030+
}
1031+
} catch (error) {
1032+
await releaseCheckoutAdmission(checkoutAdmissionClaim)
1033+
throw error
1034+
}
1035+
return { context: { billingCheckoutAdmissionClaim: checkoutAdmissionClaim } }
1036+
}
10131037
}
10141038
}
10151039

10161040
return
10171041
}),
1042+
after: createAuthMiddleware(async (ctx) => {
1043+
if (!isBillingEnabled || ctx.path !== '/subscription/upgrade') return
1044+
const checkoutContext = ctx as typeof ctx & {
1045+
billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim
1046+
}
1047+
if (checkoutContext.billingCheckoutAdmissionClaim) {
1048+
await releaseCheckoutAdmission(checkoutContext.billingCheckoutAdmissionClaim)
1049+
}
1050+
}),
10181051
},
10191052
plugins: [
10201053
...(env.TURNSTILE_SECRET_KEY

apps/sim/lib/auth/stripe-adapter-guard.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ function createBaseAdapter() {
2222
const asAdapter = (base: ReturnType<typeof createBaseAdapter>) =>
2323
guardSubscriptionPlanWrites(base as unknown as Parameters<typeof guardSubscriptionPlanWrites>[0])
2424

25-
const ORG_ROW = { id: 'sub-1', referenceId: 'org-1', plan: 'team_6000' }
25+
const ORG_ROW = {
26+
id: 'sub-1',
27+
referenceId: 'org-1',
28+
plan: 'team_6000',
29+
stripeSubscriptionId: 'stripe-sub-1',
30+
}
2631
const WHERE = [{ field: 'id', value: 'sub-1' }]
2732

2833
describe('guardSubscriptionPlanWrites', () => {
@@ -144,6 +149,87 @@ describe('guardSubscriptionPlanWrites', () => {
144149
expect(base.update).toHaveBeenCalled()
145150
})
146151

152+
it('blocks rebinding a personal subscription to a different Stripe subscription', async () => {
153+
const base = createBaseAdapter()
154+
const personalPro = {
155+
id: 'personal-pro',
156+
referenceId: 'user-1',
157+
plan: 'pro',
158+
stripeSubscriptionId: 'sub_personal_pro',
159+
}
160+
base.findOne.mockResolvedValueOnce(personalPro)
161+
162+
const guarded = asAdapter(base)
163+
await expect(
164+
guarded.update({
165+
model: 'subscription',
166+
where: [{ field: 'id', value: personalPro.id }] as never,
167+
update: {
168+
stripeSubscriptionId: 'sub_enterprise',
169+
status: 'active',
170+
periodEnd: new Date('2026-09-11T18:36:09Z'),
171+
billingInterval: 'month',
172+
},
173+
})
174+
).rejects.toThrow(/already bound to Stripe subscription sub_personal_pro/)
175+
176+
expect(base.update).not.toHaveBeenCalled()
177+
})
178+
179+
it('allows Stripe state updates when the subscription ID is unchanged', async () => {
180+
const base = createBaseAdapter()
181+
base.findOne.mockResolvedValueOnce({
182+
id: 'personal-pro',
183+
referenceId: 'user-1',
184+
plan: 'pro',
185+
stripeSubscriptionId: 'sub_personal_pro',
186+
})
187+
188+
const guarded = asAdapter(base)
189+
await guarded.update({
190+
model: 'subscription',
191+
where: WHERE as never,
192+
update: {
193+
stripeSubscriptionId: 'sub_personal_pro',
194+
status: 'active',
195+
cancelAtPeriodEnd: true,
196+
},
197+
})
198+
199+
expect(base.update).toHaveBeenCalledWith(
200+
expect.objectContaining({
201+
update: {
202+
stripeSubscriptionId: 'sub_personal_pro',
203+
status: 'active',
204+
cancelAtPeriodEnd: true,
205+
},
206+
})
207+
)
208+
})
209+
210+
it('allows binding an unbound local subscription to Stripe', async () => {
211+
const base = createBaseAdapter()
212+
base.findOne.mockResolvedValueOnce({
213+
id: 'new-subscription',
214+
referenceId: 'user-1',
215+
plan: 'pro',
216+
stripeSubscriptionId: null,
217+
})
218+
219+
const guarded = asAdapter(base)
220+
await guarded.update({
221+
model: 'subscription',
222+
where: WHERE as never,
223+
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
224+
})
225+
226+
expect(base.update).toHaveBeenCalledWith(
227+
expect.objectContaining({
228+
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
229+
})
230+
)
231+
})
232+
147233
it('rejects creating an org-referenced subscription with a non-org plan', async () => {
148234
const base = createBaseAdapter()
149235
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])

apps/sim/lib/auth/stripe-adapter-guard.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ type SubscriptionWriteSurface = Pick<
1717
/**
1818
* The Better Auth Stripe plugin persists webhook state through the raw
1919
* database adapter BEFORE invoking our subscription callbacks — including the
20-
* `plan` column resolved from the Stripe price. That makes the adapter the
21-
* only in-process seam that can enforce the billing invariant that
22-
* organization-referenced subscriptions hold Team/Enterprise plans: by the
23-
* time `syncSubscriptionPlan` runs in a callback, the plugin's write has
24-
* already landed.
20+
* Stripe subscription ID and `plan` column resolved from the Stripe price.
21+
* That makes the adapter the only in-process seam that can enforce two billing
22+
* invariants before the write lands:
23+
*
24+
* - an existing subscription cannot be rebound to a different Stripe
25+
* subscription merely because both subscriptions share a customer;
26+
* - organization-referenced subscriptions hold Team/Enterprise plans.
2527
*
2628
* Checkout admission blocks user-driven violations; this guard blocks the
2729
* remaining vector — an operator swapping an org subscription onto a personal
@@ -77,11 +79,31 @@ function guardWriteSurface<TAdapter extends SubscriptionWriteSurface>(
7779
return adapter.create(data)
7880
},
7981
update: async (data) => {
80-
if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) {
82+
if (data.model === 'subscription' && needsSubscriptionRowInspection(data.update)) {
8183
const row = await adapter.findOne<SubscriptionRowSlice>({
8284
model: 'subscription',
8385
where: data.where,
8486
})
87+
88+
if (row && attemptsStripeSubscriptionRebind(row, data.update)) {
89+
const rejectedStripeSubscriptionId = (data.update as { stripeSubscriptionId: string })
90+
.stripeSubscriptionId
91+
logger.error(
92+
'Blocked rebinding an existing subscription to a different Stripe subscription',
93+
{
94+
subscriptionId: row.id,
95+
referenceId: row.referenceId,
96+
currentStripeSubscriptionId: row.stripeSubscriptionId,
97+
rejectedStripeSubscriptionId,
98+
}
99+
)
100+
throw new Error(
101+
`Subscription ${row.id} is already bound to Stripe subscription ${row.stripeSubscriptionId}; refusing to bind ${rejectedStripeSubscriptionId}`
102+
)
103+
}
104+
105+
if (!hasNonOrgPlanWrite(data.update)) return adapter.update(data)
106+
85107
const sanitized = await stripPlanWhenOrgReferenced(
86108
row ? [row] : [],
87109
data.update as Record<string, unknown>
@@ -110,6 +132,27 @@ interface SubscriptionRowSlice {
110132
id: string
111133
referenceId: string
112134
plan: string
135+
stripeSubscriptionId: string | null
136+
}
137+
138+
function needsSubscriptionRowInspection(update: unknown): boolean {
139+
return hasStripeSubscriptionIdWrite(update) || hasNonOrgPlanWrite(update)
140+
}
141+
142+
function hasStripeSubscriptionIdWrite(
143+
update: unknown
144+
): update is { stripeSubscriptionId: unknown } {
145+
return Boolean(update && typeof update === 'object' && 'stripeSubscriptionId' in update)
146+
}
147+
148+
function attemptsStripeSubscriptionRebind(row: SubscriptionRowSlice, update: unknown): boolean {
149+
if (!hasStripeSubscriptionIdWrite(update)) return false
150+
const incomingStripeSubscriptionId = update.stripeSubscriptionId
151+
return (
152+
typeof row.stripeSubscriptionId === 'string' &&
153+
typeof incomingStripeSubscriptionId === 'string' &&
154+
incomingStripeSubscriptionId !== row.stripeSubscriptionId
155+
)
113156
}
114157

115158
function hasNonOrgPlanWrite(update: unknown): boolean {

apps/sim/lib/billing/authorization.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { resetDbChainMock } from '@sim/testing'
4+
import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing'
55
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
@@ -96,6 +96,20 @@ describe('authorizeSubscriptionReference', () => {
9696
expect(mockIsOwnerOrAdmin).toHaveBeenCalledWith('owner-1', 'org-1')
9797
})
9898

99+
it('blocks an organization checkout while its bound Stripe subscription is incomplete', async () => {
100+
dbChainMockFns.limit.mockResolvedValueOnce([
101+
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
102+
])
103+
104+
await expect(
105+
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'team_6000')
106+
).rejects.toThrow(/subscription payment is still processing/)
107+
108+
expect(mockHasPaidSubscription).not.toHaveBeenCalled()
109+
expect(mockAssertNoUnresolved).not.toHaveBeenCalled()
110+
expect(mockIsOwnerOrAdmin).not.toHaveBeenCalled()
111+
})
112+
99113
it('rejects an organization checkout for a pro plan — org references only hold Team/Enterprise', async () => {
100114
await expect(
101115
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'pro_6000')
@@ -148,6 +162,46 @@ describe('assertPersonalCheckoutAllowed', () => {
148162
await expect(assertPersonalCheckoutAllowed('user-1')).resolves.toBeUndefined()
149163
})
150164

165+
it('keeps abandoned, unbound checkout placeholders retryable', async () => {
166+
await assertPersonalCheckoutAllowed('user-1')
167+
168+
const predicate = dbChainMockFns.where.mock.calls[0]?.[0]
169+
expect(
170+
hasMockCondition(
171+
predicate,
172+
(node) => node.type === 'eq' && node.left === schemaMock.subscription.referenceId
173+
)
174+
).toBe(true)
175+
expect(
176+
hasMockCondition(
177+
predicate,
178+
(node) =>
179+
node.type === 'eq' &&
180+
node.left === schemaMock.subscription.status &&
181+
node.right === 'incomplete'
182+
)
183+
).toBe(true)
184+
expect(
185+
hasMockCondition(
186+
predicate,
187+
(node) =>
188+
node.type === 'isNotNull' && node.column === schemaMock.subscription.stripeSubscriptionId
189+
)
190+
).toBe(true)
191+
})
192+
193+
it('blocks a personal checkout while its bound Stripe subscription is incomplete', async () => {
194+
dbChainMockFns.limit.mockResolvedValueOnce([
195+
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
196+
])
197+
198+
await expect(assertPersonalCheckoutAllowed('user-1')).rejects.toThrow(
199+
/subscription payment is still processing/
200+
)
201+
202+
expect(mockGetOrganizationCoverageForMember).not.toHaveBeenCalled()
203+
})
204+
151205
it('rejects checkout when an organization subscription already covers the user', async () => {
152206
mockGetOrganizationCoverageForMember.mockResolvedValueOnce({
153207
status: 'covered',

0 commit comments

Comments
 (0)