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
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ describe('useUpgradeState', () => {
})
})

it('shows checkout admission failures through the standard error toast', async () => {
mockHandleUpgrade.mockRejectedValueOnce(
new Error('Your subscription payment is still processing.')
)

await act(async () => {
root.render(<Harness />)
})

await act(async () => {
await currentState?.doUpgrade('team', 25000)
})

expect(mockToastError).toHaveBeenCalledWith('Your subscription payment is still processing.')
})

it('includes the routed workspace when switching the host billing interval', async () => {
await act(async () => {
root.render(<Harness />)
Expand Down
43 changes: 38 additions & 5 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ import {
} from '@/lib/auth/constants'
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
import { clampExpiryForSession } from '@/lib/auth/session-policy'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import {
assertPersonalCheckoutAllowed,
authorizeSubscriptionReference,
isPersonalCheckoutRequest,
} from '@/lib/billing/authorization'
import {
type CheckoutAdmissionClaim,
claimCheckoutAdmission,
releaseCheckoutAdmission,
resolveCheckoutReferenceId,
} from '@/lib/billing/checkout-admission'
import {
getOrganizationIdForSubscriptionReference,
syncSubscriptionPlan,
Expand Down Expand Up @@ -1001,20 +1008,46 @@ export const auth = betterAuth({
/**
* Personal checkout guard. The Stripe plugin's `authorizeReference`
* only runs for organization references (it skips references equal to
* the session user), so duplicate-coverage enforcement for personal
* checkouts lives here: a member of an org with an entitled paid
* subscription must not buy a personal plan on top of it.
* the session user), so personal checkout admission lives here. It
* prevents both a duplicate checkout while Stripe payment is pending
* and a personal plan for someone already covered by an organization.
*/
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
const session = await getSessionFromCtx(ctx)
const sessionUserId = session?.user?.id
if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) {
await assertPersonalCheckoutAllowed(sessionUserId)
if (sessionUserId) {
const requestBody = ctx.body ?? {}
const referenceId = resolveCheckoutReferenceId(
requestBody,
sessionUserId,
getActiveOrganizationId(session)
)
if (referenceId) {
const checkoutAdmissionClaim = await claimCheckoutAdmission(referenceId)
try {
if (isPersonalCheckoutRequest(requestBody, sessionUserId)) {
await assertPersonalCheckoutAllowed(sessionUserId)
}
} catch (error) {
await releaseCheckoutAdmission(checkoutAdmissionClaim)
throw error
}
return { context: { billingCheckoutAdmissionClaim: checkoutAdmissionClaim } }
}
}
}

return
}),
after: createAuthMiddleware(async (ctx) => {
if (!isBillingEnabled || ctx.path !== '/subscription/upgrade') return
const checkoutContext = ctx as typeof ctx & {
billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim
}
if (checkoutContext.billingCheckoutAdmissionClaim) {
await releaseCheckoutAdmission(checkoutContext.billingCheckoutAdmissionClaim)
}
Comment thread
icecrasher321 marked this conversation as resolved.
}),
},
plugins: [
...(env.TURNSTILE_SECRET_KEY
Expand Down
88 changes: 87 additions & 1 deletion apps/sim/lib/auth/stripe-adapter-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ function createBaseAdapter() {
const asAdapter = (base: ReturnType<typeof createBaseAdapter>) =>
guardSubscriptionPlanWrites(base as unknown as Parameters<typeof guardSubscriptionPlanWrites>[0])

const ORG_ROW = { id: 'sub-1', referenceId: 'org-1', plan: 'team_6000' }
const ORG_ROW = {
id: 'sub-1',
referenceId: 'org-1',
plan: 'team_6000',
stripeSubscriptionId: 'stripe-sub-1',
}
const WHERE = [{ field: 'id', value: 'sub-1' }]

describe('guardSubscriptionPlanWrites', () => {
Expand Down Expand Up @@ -144,6 +149,87 @@ describe('guardSubscriptionPlanWrites', () => {
expect(base.update).toHaveBeenCalled()
})

it('blocks rebinding a personal subscription to a different Stripe subscription', async () => {
const base = createBaseAdapter()
const personalPro = {
id: 'personal-pro',
referenceId: 'user-1',
plan: 'pro',
stripeSubscriptionId: 'sub_personal_pro',
}
base.findOne.mockResolvedValueOnce(personalPro)

const guarded = asAdapter(base)
await expect(
guarded.update({
model: 'subscription',
where: [{ field: 'id', value: personalPro.id }] as never,
update: {
stripeSubscriptionId: 'sub_enterprise',
status: 'active',
periodEnd: new Date('2026-09-11T18:36:09Z'),
billingInterval: 'month',
},
})
).rejects.toThrow(/already bound to Stripe subscription sub_personal_pro/)

expect(base.update).not.toHaveBeenCalled()
})

it('allows Stripe state updates when the subscription ID is unchanged', async () => {
const base = createBaseAdapter()
base.findOne.mockResolvedValueOnce({
id: 'personal-pro',
referenceId: 'user-1',
plan: 'pro',
stripeSubscriptionId: 'sub_personal_pro',
})

const guarded = asAdapter(base)
await guarded.update({
model: 'subscription',
where: WHERE as never,
update: {
stripeSubscriptionId: 'sub_personal_pro',
status: 'active',
cancelAtPeriodEnd: true,
},
})

expect(base.update).toHaveBeenCalledWith(
expect.objectContaining({
update: {
stripeSubscriptionId: 'sub_personal_pro',
status: 'active',
cancelAtPeriodEnd: true,
},
})
)
})

it('allows binding an unbound local subscription to Stripe', async () => {
const base = createBaseAdapter()
base.findOne.mockResolvedValueOnce({
id: 'new-subscription',
referenceId: 'user-1',
plan: 'pro',
stripeSubscriptionId: null,
})

const guarded = asAdapter(base)
await guarded.update({
model: 'subscription',
where: WHERE as never,
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
})

expect(base.update).toHaveBeenCalledWith(
expect.objectContaining({
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
})
)
})

it('rejects creating an org-referenced subscription with a non-org plan', async () => {
const base = createBaseAdapter()
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
Expand Down
55 changes: 49 additions & 6 deletions apps/sim/lib/auth/stripe-adapter-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ type SubscriptionWriteSurface = Pick<
/**
* The Better Auth Stripe plugin persists webhook state through the raw
* database adapter BEFORE invoking our subscription callbacks — including the
* `plan` column resolved from the Stripe price. That makes the adapter the
* only in-process seam that can enforce the billing invariant that
* organization-referenced subscriptions hold Team/Enterprise plans: by the
* time `syncSubscriptionPlan` runs in a callback, the plugin's write has
* already landed.
* Stripe subscription ID and `plan` column resolved from the Stripe price.
* That makes the adapter the only in-process seam that can enforce two billing
* invariants before the write lands:
*
* - an existing subscription cannot be rebound to a different Stripe
* subscription merely because both subscriptions share a customer;
* - organization-referenced subscriptions hold Team/Enterprise plans.
*
* Checkout admission blocks user-driven violations; this guard blocks the
* remaining vector — an operator swapping an org subscription onto a personal
Expand Down Expand Up @@ -77,11 +79,31 @@ function guardWriteSurface<TAdapter extends SubscriptionWriteSurface>(
return adapter.create(data)
},
update: async (data) => {
if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) {
if (data.model === 'subscription' && needsSubscriptionRowInspection(data.update)) {
const row = await adapter.findOne<SubscriptionRowSlice>({
model: 'subscription',
where: data.where,
})

if (row && attemptsStripeSubscriptionRebind(row, data.update)) {
const rejectedStripeSubscriptionId = (data.update as { stripeSubscriptionId: string })
.stripeSubscriptionId
logger.error(
'Blocked rebinding an existing subscription to a different Stripe subscription',
{
subscriptionId: row.id,
referenceId: row.referenceId,
currentStripeSubscriptionId: row.stripeSubscriptionId,
rejectedStripeSubscriptionId,
}
)
throw new Error(
`Subscription ${row.id} is already bound to Stripe subscription ${row.stripeSubscriptionId}; refusing to bind ${rejectedStripeSubscriptionId}`
)
}
Comment thread
icecrasher321 marked this conversation as resolved.

if (!hasNonOrgPlanWrite(data.update)) return adapter.update(data)

const sanitized = await stripPlanWhenOrgReferenced(
row ? [row] : [],
data.update as Record<string, unknown>
Expand Down Expand Up @@ -110,6 +132,27 @@ interface SubscriptionRowSlice {
id: string
referenceId: string
plan: string
stripeSubscriptionId: string | null
}

function needsSubscriptionRowInspection(update: unknown): boolean {
return hasStripeSubscriptionIdWrite(update) || hasNonOrgPlanWrite(update)
}

function hasStripeSubscriptionIdWrite(
update: unknown
): update is { stripeSubscriptionId: unknown } {
return Boolean(update && typeof update === 'object' && 'stripeSubscriptionId' in update)
}

function attemptsStripeSubscriptionRebind(row: SubscriptionRowSlice, update: unknown): boolean {
if (!hasStripeSubscriptionIdWrite(update)) return false
const incomingStripeSubscriptionId = update.stripeSubscriptionId
return (
typeof row.stripeSubscriptionId === 'string' &&
typeof incomingStripeSubscriptionId === 'string' &&
incomingStripeSubscriptionId !== row.stripeSubscriptionId
)
}

function hasNonOrgPlanWrite(update: unknown): boolean {
Expand Down
56 changes: 55 additions & 1 deletion apps/sim/lib/billing/authorization.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
import { resetDbChainMock } from '@sim/testing'
import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const {
Expand Down Expand Up @@ -96,6 +96,20 @@ describe('authorizeSubscriptionReference', () => {
expect(mockIsOwnerOrAdmin).toHaveBeenCalledWith('owner-1', 'org-1')
})

it('blocks an organization checkout while its bound Stripe subscription is incomplete', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
])

await expect(
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'team_6000')
).rejects.toThrow(/subscription payment is still processing/)

expect(mockHasPaidSubscription).not.toHaveBeenCalled()
expect(mockAssertNoUnresolved).not.toHaveBeenCalled()
expect(mockIsOwnerOrAdmin).not.toHaveBeenCalled()
})

it('rejects an organization checkout for a pro plan — org references only hold Team/Enterprise', async () => {
await expect(
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'pro_6000')
Expand Down Expand Up @@ -148,6 +162,46 @@ describe('assertPersonalCheckoutAllowed', () => {
await expect(assertPersonalCheckoutAllowed('user-1')).resolves.toBeUndefined()
})

it('keeps abandoned, unbound checkout placeholders retryable', async () => {
await assertPersonalCheckoutAllowed('user-1')

const predicate = dbChainMockFns.where.mock.calls[0]?.[0]
expect(
hasMockCondition(
predicate,
(node) => node.type === 'eq' && node.left === schemaMock.subscription.referenceId
)
).toBe(true)
expect(
hasMockCondition(
predicate,
(node) =>
node.type === 'eq' &&
node.left === schemaMock.subscription.status &&
node.right === 'incomplete'
)
).toBe(true)
expect(
hasMockCondition(
predicate,
(node) =>
node.type === 'isNotNull' && node.column === schemaMock.subscription.stripeSubscriptionId
)
).toBe(true)
})

it('blocks a personal checkout while its bound Stripe subscription is incomplete', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
])

await expect(assertPersonalCheckoutAllowed('user-1')).rejects.toThrow(
/subscription payment is still processing/
)

expect(mockGetOrganizationCoverageForMember).not.toHaveBeenCalled()
})

it('rejects checkout when an organization subscription already covers the user', async () => {
mockGetOrganizationCoverageForMember.mockResolvedValueOnce({
status: 'covered',
Expand Down
Loading
Loading