Skip to content

Commit 768c389

Browse files
fix(billing): scope subscription limit syncs to the exact payer (#7695)
* fix(billing): scope subscription limit syncs to the exact payer * fix(billing): retry subscription limit reconciliation through webhook events * chore(tests): remove timing-dependent search setup case
1 parent 54c3392 commit 768c389

10 files changed

Lines changed: 332 additions & 162 deletions

File tree

apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -413,47 +413,6 @@ describe('organization setup entry points', () => {
413413
expect(mocks.push).toHaveBeenCalledWith('/o/org-1/settings/integrations/sources/new-source')
414414
})
415415

416-
it('honors explicit member-source URLs and clears both setup parameters on close', async () => {
417-
useConnectorSetupStore
418-
.getState()
419-
.saveDraft('user-1:organization:org-1:kb-search:github:members', {
420-
sourceConfig: { repository: 'acme/docs' },
421-
canonicalModes: {},
422-
accessMode: 'members',
423-
credentialId: 'cred-source',
424-
contentCredentialId: null,
425-
disabledTagIds: [],
426-
savedAt: Date.now(),
427-
})
428-
await render(organizationSetup(), '?addConnector=github&source-access=members&search=keep')
429-
430-
expect(mocks.replace).not.toHaveBeenCalled()
431-
expect(document.querySelector('button[aria-label="Choose another source"]')).toBeNull()
432-
expect(document.body.textContent).not.toContain('Sync using')
433-
expect(document.body.textContent).toContain('Sync documents with')
434-
expect(button('Add source')).toBeEnabled()
435-
await click(button('Add source'))
436-
expect(mocks.create).toHaveBeenCalledWith(
437-
expect.objectContaining({
438-
connectorType: 'github',
439-
accessMode: 'members',
440-
sourceConfig: { repository: 'acme/docs' },
441-
}),
442-
expect.any(Object)
443-
)
444-
await click(button('Cancel'))
445-
expect(mocks.urlUpdate).toHaveBeenLastCalledWith(
446-
expect.objectContaining({ queryString: '?search=keep' })
447-
)
448-
expect(document.querySelector('[role="dialog"]')).toBeNull()
449-
expect(mocks.push).not.toHaveBeenCalled()
450-
expect(
451-
useConnectorSetupStore
452-
.getState()
453-
.getDraft('user-1:organization:org-1:kb-search:github:members')
454-
).toBeUndefined()
455-
})
456-
457416
it.each(['github', 'gmail', 'google_calendar', 'jira'])(
458417
'returns old %s organization setup links to personal integrations without loading the index',
459418
async (type) => {

apps/sim/lib/auth/auth.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import {
101101
handleSubscriptionCreated,
102102
handleSubscriptionDeleted,
103103
} from '@/lib/billing/webhooks/subscription'
104+
import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage'
104105
import { env } from '@/lib/core/config/env'
105106
import {
106107
isAuthDisabled,
@@ -1615,16 +1616,6 @@ export const auth = betterAuth({
16151616
throw orgError
16161617
}
16171618

1618-
try {
1619-
await syncSubscriptionUsageLimits(resolvedSubscription)
1620-
} catch (error) {
1621-
logger.error('[onSubscriptionUpdate] Failed to sync usage limits', {
1622-
subscriptionId: resolvedSubscription.id,
1623-
referenceId: resolvedSubscription.referenceId,
1624-
error,
1625-
})
1626-
}
1627-
16281619
if (isTeam(effectivePlanForTeamFeatures)) {
16291620
try {
16301621
const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1
@@ -1703,6 +1694,7 @@ export const auth = betterAuth({
17031694
case 'customer.subscription.created':
17041695
case 'customer.subscription.updated': {
17051696
await handleManualEnterpriseSubscription(event)
1697+
await handleSubscriptionUsageUpdate(event)
17061698
break
17071699
}
17081700
case 'checkout.session.expired': {

apps/sim/lib/billing/core/subscription.test.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
5-
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import {
5+
dbChainMockFns,
6+
queueTableRows,
7+
resetDbChainMock,
8+
resetEnvFlagsMock,
9+
schemaMock,
10+
setEnvFlags,
11+
} from '@sim/testing'
12+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
613

714
const {
815
mockGetHighestPrioritySubscription,
@@ -190,20 +197,39 @@ describe('getOrganizationCoverageForMember', () => {
190197
describe('getOrganizationIdForSubscriptionReference', () => {
191198
beforeEach(() => {
192199
vi.clearAllMocks()
200+
resetDbChainMock()
193201
})
194202

195-
it('returns an organization id directly when the reference already points to one', async () => {
196-
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
203+
afterEach(resetDbChainMock)
197204

198-
await expect(getOrganizationIdForSubscriptionReference('org-1')).resolves.toBe('org-1')
199-
})
205+
it.each(['org-1', 'legacy-organization-id'])(
206+
'returns the directly referenced organization %s',
207+
async (organizationId) => {
208+
queueTableRows(schemaMock.organization, [{ id: organizationId }])
209+
210+
await expect(getOrganizationIdForSubscriptionReference(organizationId)).resolves.toBe(
211+
organizationId
212+
)
213+
}
214+
)
215+
216+
it.each(['owner', 'admin', 'member'])(
217+
'keeps a personal subscription personal when its user is an organization %s',
218+
async (role) => {
219+
queueTableRows(schemaMock.organization, [])
220+
queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role }])
200221

201-
it('falls back to the admin-owned organization when the reference is still user-scoped', async () => {
202-
dbChainMockFns.limit
203-
.mockResolvedValueOnce([])
204-
.mockResolvedValueOnce([{ organizationId: 'org-1', role: 'owner' }])
222+
await expect(getOrganizationIdForSubscriptionReference('user-1')).resolves.toBeNull()
223+
expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member)
224+
}
225+
)
205226

206-
await expect(getOrganizationIdForSubscriptionReference('user-1')).resolves.toBe('org-1')
227+
it('propagates lookup errors instead of treating the subscription as personal', async () => {
228+
dbChainMockFns.limit.mockRejectedValueOnce(new Error('db unavailable'))
229+
230+
await expect(getOrganizationIdForSubscriptionReference('org-1')).rejects.toThrow(
231+
'db unavailable'
232+
)
207233
})
208234
})
209235

apps/sim/lib/billing/core/subscription.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { cache } from 'react'
22
import { db } from '@sim/db'
33
import { member, organization, subscription, user } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5-
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
65
import { and, eq, inArray, sql } from 'drizzle-orm'
76
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
87
import {
@@ -275,6 +274,7 @@ export async function getOrganizationCoverageForMember(
275274
}
276275
}
277276

277+
/** Resolves the subscription's exact organization reference without inferring ownership from membership. */
278278
export async function getOrganizationIdForSubscriptionReference(
279279
referenceId: string
280280
): Promise<string | null> {
@@ -284,24 +284,7 @@ export async function getOrganizationIdForSubscriptionReference(
284284
.where(eq(organization.id, referenceId))
285285
.limit(1)
286286

287-
if (referencedOrganization) {
288-
return referencedOrganization.id
289-
}
290-
291-
const [memberRecord] = await db
292-
.select({
293-
organizationId: member.organizationId,
294-
role: member.role,
295-
})
296-
.from(member)
297-
.where(eq(member.userId, referenceId))
298-
.limit(1)
299-
300-
if (memberRecord && isOrgAdminRole(memberRecord.role)) {
301-
return memberRecord.organizationId
302-
}
303-
304-
return null
287+
return referencedOrganization?.id ?? null
305288
}
306289

307290
/**

apps/sim/lib/billing/core/usage.test.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
/**
22
* Tests for getUserUsageLimit.
33
*
4-
* Org-scoped members carry a null `currentUsageLimit` by design, so a user
5-
* whose subscription stops being org-scoped without a resync is left null.
6-
* The limit read must self-heal that state to the plan/free base plus prepaid
7-
* balance instead of failing closed and blocking every execution.
4+
* Legacy membership syncs may leave a null personal usage limit. The limit
5+
* read must recover the plan/free base plus prepaid balance, and subsequent
6+
* subscription syncs must preserve independent personal and organization pools.
87
*
98
* @vitest-environment node
109
*/
@@ -25,12 +24,14 @@ afterAll(() => {
2524
const {
2625
mockGetFreeTierLimit,
2726
mockGetHighestPrioritySubscription,
27+
mockGetHighestPriorityPersonalSubscription,
2828
mockGetPerUserMinimumLimit,
2929
mockHasPaidSubscriptionStatus,
3030
mockIsOrgScopedSubscription,
3131
} = vi.hoisted(() => ({
3232
mockGetFreeTierLimit: vi.fn(),
3333
mockGetHighestPrioritySubscription: vi.fn(),
34+
mockGetHighestPriorityPersonalSubscription: vi.fn(),
3435
mockGetPerUserMinimumLimit: vi.fn(),
3536
mockHasPaidSubscriptionStatus: vi.fn(),
3637
mockIsOrgScopedSubscription: vi.fn(),
@@ -48,6 +49,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({
4849

4950
vi.mock('@/lib/billing/core/plan', () => ({
5051
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
52+
getHighestPriorityPersonalSubscription: mockGetHighestPriorityPersonalSubscription,
5153
}))
5254

5355
vi.mock('@/lib/billing/core/access', () => ({
@@ -205,10 +207,49 @@ describe('syncUsageLimitsFromSubscription', () => {
205207
vi.clearAllMocks()
206208
resetDbChainMock()
207209
mockIsOrgScopedSubscription.mockReturnValue(false)
210+
mockHasPaidSubscriptionStatus.mockImplementation((status: string) => status === 'active')
211+
})
212+
213+
it.each([
214+
{ plan: 'pro', minimum: 40 },
215+
{ plan: 'enterprise', minimum: 0 },
216+
])(
217+
'preserves a personal $plan cap when the user also belongs to an enterprise organization',
218+
async ({ plan, minimum }) => {
219+
const personalSubscription = { plan, referenceId: 'user-1', status: 'active' }
220+
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(personalSubscription)
221+
mockGetHighestPrioritySubscription.mockResolvedValue({
222+
plan: 'enterprise',
223+
referenceId: 'org-1',
224+
status: 'active',
225+
})
226+
mockIsOrgScopedSubscription.mockReturnValue(true)
227+
mockGetPerUserMinimumLimit.mockReturnValue(minimum)
228+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80', creditBalance: '1' }])
229+
230+
await syncUsageLimitsFromSubscription('user-1')
231+
232+
expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledExactlyOnceWith('user-1', {
233+
onError: 'throw',
234+
})
235+
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
236+
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(personalSubscription)
237+
const update = dbChainMockFns.set.mock.calls[0]?.[0]
238+
expect(update?.currentUsageLimit).not.toBeNull()
239+
expect(JSON.stringify(update?.currentUsageLimit)).toContain('greatest')
240+
}
241+
)
242+
243+
it('does not reset a personal cap when its subscription lookup fails', async () => {
244+
mockGetHighestPriorityPersonalSubscription.mockRejectedValueOnce(new Error('db unavailable'))
245+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80' }])
246+
247+
await expect(syncUsageLimitsFromSubscription('user-1')).rejects.toThrow('db unavailable')
248+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
208249
})
209250

210251
it('raises a paid personal limit to plan base plus the exact prepaid balance', async () => {
211-
mockGetHighestPrioritySubscription.mockResolvedValue(PRO_SUBSCRIPTION)
252+
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(PRO_SUBSCRIPTION)
212253
mockGetPerUserMinimumLimit.mockReturnValue(40)
213254
dbChainMockFns.limit.mockResolvedValueOnce([
214255
{ currentUsageLimit: '40', creditBalance: '0.005' },
@@ -224,7 +265,7 @@ describe('syncUsageLimitsFromSubscription', () => {
224265
})
225266

226267
it('restores free-tier base plus prepaid after a downgrade or org departure', async () => {
227-
mockGetHighestPrioritySubscription.mockResolvedValue(null)
268+
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
228269
mockGetPerUserMinimumLimit.mockReturnValue(10)
229270
dbChainMockFns.limit.mockResolvedValueOnce([
230271
{ currentUsageLimit: null, creditBalance: '0.006' },
@@ -240,7 +281,7 @@ describe('syncUsageLimitsFromSubscription', () => {
240281
})
241282

242283
it('does not retain a higher paid custom cap after downgrade to free', async () => {
243-
mockGetHighestPrioritySubscription.mockResolvedValue(null)
284+
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null)
244285
mockGetPerUserMinimumLimit.mockReturnValue(10)
245286
dbChainMockFns.limit.mockResolvedValueOnce([
246287
{ currentUsageLimit: '100', creditBalance: '0.006' },
@@ -256,7 +297,7 @@ describe('syncUsageLimitsFromSubscription', () => {
256297
})
257298

258299
it('preserves a higher custom personal limit', async () => {
259-
mockGetHighestPrioritySubscription.mockResolvedValue(PRO_SUBSCRIPTION)
300+
mockGetHighestPriorityPersonalSubscription.mockResolvedValue(PRO_SUBSCRIPTION)
260301
mockGetPerUserMinimumLimit.mockReturnValue(40)
261302
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '50', creditBalance: '1' }])
262303

apps/sim/lib/billing/core/usage.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { and, eq, isNull, sql } from 'drizzle-orm'
77
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
88
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
99
import {
10+
getHighestPriorityPersonalSubscription,
1011
getHighestPrioritySubscription,
1112
type HighestPrioritySubscription,
1213
} from '@/lib/billing/core/plan'
@@ -449,13 +450,11 @@ export async function updateUserUsageLimit(
449450
* checks). Org-scoped subs return the organization limit;
450451
* personally-scoped subs return the individual user limit from userStats.
451452
*
452-
* Org-scoped members carry a null `currentUsageLimit` by design (see
453-
* `syncUsageLimitsFromSubscription`). A user whose subscription stops being
454-
* org-scoped without a resync would otherwise stay null and fail closed on
455-
* every execution, so a null limit self-heals to the plan/free base plus the
456-
* exact prepaid balance here. The write-back is best-effort: a limit written
457-
* concurrently wins, and a failed write still resolves to the fallback
458-
* instead of blocking execution.
453+
* Legacy organization membership syncs may have cleared the personal limit.
454+
* A null limit self-heals to the personal plan/free base plus the exact prepaid
455+
* balance here. The write-back is best-effort: a limit written concurrently
456+
* wins, and a failed write still resolves to the fallback instead of blocking
457+
* execution.
459458
*/
460459
export async function getUserUsageLimit(
461460
userId: string,
@@ -576,37 +575,19 @@ export async function checkUsageStatus(userId: string): Promise<{
576575
}
577576

578577
/**
579-
* Sync usage limits based on subscription changes
578+
* Syncs the user's personal billing pool from their exact personal subscription.
579+
* Organization subscriptions have a separate pool and never clear personal limits.
580580
*/
581581
export async function syncUsageLimitsFromSubscription(userId: string): Promise<void> {
582582
const [subscription, currentUserStats] = await Promise.all([
583-
getHighestPrioritySubscription(userId),
583+
getHighestPriorityPersonalSubscription(userId, { onError: 'throw' }),
584584
db.select(userStatsColumns).from(userStats).where(eq(userStats.userId, userId)).limit(1),
585585
])
586586

587587
if (currentUserStats.length === 0) {
588588
throw new Error(`User stats not found for userId: ${userId}`)
589589
}
590590

591-
const currentStats = currentUserStats[0]
592-
593-
if (isOrgScopedSubscription(subscription, userId)) {
594-
if (currentStats.currentUsageLimit !== null) {
595-
await db
596-
.update(userStats)
597-
.set({
598-
currentUsageLimit: null,
599-
usageLimitUpdatedAt: new Date(),
600-
})
601-
.where(eq(userStats.userId, userId))
602-
603-
logger.info('Cleared individual limit for org-scoped member', {
604-
userId,
605-
plan: subscription?.plan,
606-
})
607-
}
608-
return
609-
}
610591
const baseLimit = toDecimal(getPerUserMinimumLimit(subscription)).toString()
611592
const hasEntitledPersonalSubscription =
612593
subscription !== null && hasPaidSubscriptionStatus(subscription.status)
@@ -634,7 +615,6 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise<v
634615
: 'Reset limit to free-plus-prepaid minimum',
635616
{ userId, baseLimit: Number(baseLimit) }
636617
)
637-
// Keep higher custom limits unchanged only while personal billing is entitled.
638618
}
639619

640620
/**

0 commit comments

Comments
 (0)