diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx index 838cb7aec2f..1edc21572e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx @@ -413,47 +413,6 @@ describe('organization setup entry points', () => { expect(mocks.push).toHaveBeenCalledWith('/o/org-1/settings/integrations/sources/new-source') }) - it('honors explicit member-source URLs and clears both setup parameters on close', async () => { - useConnectorSetupStore - .getState() - .saveDraft('user-1:organization:org-1:kb-search:github:members', { - sourceConfig: { repository: 'acme/docs' }, - canonicalModes: {}, - accessMode: 'members', - credentialId: 'cred-source', - contentCredentialId: null, - disabledTagIds: [], - savedAt: Date.now(), - }) - await render(organizationSetup(), '?addConnector=github&source-access=members&search=keep') - - expect(mocks.replace).not.toHaveBeenCalled() - expect(document.querySelector('button[aria-label="Choose another source"]')).toBeNull() - expect(document.body.textContent).not.toContain('Sync using') - expect(document.body.textContent).toContain('Sync documents with') - expect(button('Add source')).toBeEnabled() - await click(button('Add source')) - expect(mocks.create).toHaveBeenCalledWith( - expect.objectContaining({ - connectorType: 'github', - accessMode: 'members', - sourceConfig: { repository: 'acme/docs' }, - }), - expect.any(Object) - ) - await click(button('Cancel')) - expect(mocks.urlUpdate).toHaveBeenLastCalledWith( - expect.objectContaining({ queryString: '?search=keep' }) - ) - expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(mocks.push).not.toHaveBeenCalled() - expect( - useConnectorSetupStore - .getState() - .getDraft('user-1:organization:org-1:kb-search:github:members') - ).toBeUndefined() - }) - it.each(['github', 'gmail', 'google_calendar', 'jira'])( 'returns old %s organization setup links to personal integrations without loading the index', async (type) => { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 3cb2ee6b406..ace5b81ed3a 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -101,6 +101,7 @@ import { handleSubscriptionCreated, handleSubscriptionDeleted, } from '@/lib/billing/webhooks/subscription' +import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage' import { env } from '@/lib/core/config/env' import { isAuthDisabled, @@ -1615,16 +1616,6 @@ export const auth = betterAuth({ throw orgError } - try { - await syncSubscriptionUsageLimits(resolvedSubscription) - } catch (error) { - logger.error('[onSubscriptionUpdate] Failed to sync usage limits', { - subscriptionId: resolvedSubscription.id, - referenceId: resolvedSubscription.referenceId, - error, - }) - } - if (isTeam(effectivePlanForTeamFeatures)) { try { const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1 @@ -1703,6 +1694,7 @@ export const auth = betterAuth({ case 'customer.subscription.created': case 'customer.subscription.updated': { await handleManualEnterpriseSubscription(event) + await handleSubscriptionUsageUpdate(event) break } case 'checkout.session.expired': { diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index b61fbbf72ff..dbf656a9fef 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -1,8 +1,15 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetHighestPrioritySubscription, @@ -190,20 +197,39 @@ describe('getOrganizationCoverageForMember', () => { describe('getOrganizationIdForSubscriptionReference', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) - it('returns an organization id directly when the reference already points to one', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }]) + afterEach(resetDbChainMock) - await expect(getOrganizationIdForSubscriptionReference('org-1')).resolves.toBe('org-1') - }) + it.each(['org-1', 'legacy-organization-id'])( + 'returns the directly referenced organization %s', + async (organizationId) => { + queueTableRows(schemaMock.organization, [{ id: organizationId }]) + + await expect(getOrganizationIdForSubscriptionReference(organizationId)).resolves.toBe( + organizationId + ) + } + ) + + it.each(['owner', 'admin', 'member'])( + 'keeps a personal subscription personal when its user is an organization %s', + async (role) => { + queueTableRows(schemaMock.organization, []) + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role }]) - it('falls back to the admin-owned organization when the reference is still user-scoped', async () => { - dbChainMockFns.limit - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ organizationId: 'org-1', role: 'owner' }]) + await expect(getOrganizationIdForSubscriptionReference('user-1')).resolves.toBeNull() + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) + } + ) - await expect(getOrganizationIdForSubscriptionReference('user-1')).resolves.toBe('org-1') + it('propagates lookup errors instead of treating the subscription as personal', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('db unavailable')) + + await expect(getOrganizationIdForSubscriptionReference('org-1')).rejects.toThrow( + 'db unavailable' + ) }) }) diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index cea5d8a10ef..9b5c3717a92 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -2,7 +2,6 @@ import { cache } from 'react' import { db } from '@sim/db' import { member, organization, subscription, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, sql } from 'drizzle-orm' import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access' import { @@ -275,6 +274,7 @@ export async function getOrganizationCoverageForMember( } } +/** Resolves the subscription's exact organization reference without inferring ownership from membership. */ export async function getOrganizationIdForSubscriptionReference( referenceId: string ): Promise { @@ -284,24 +284,7 @@ export async function getOrganizationIdForSubscriptionReference( .where(eq(organization.id, referenceId)) .limit(1) - if (referencedOrganization) { - return referencedOrganization.id - } - - const [memberRecord] = await db - .select({ - organizationId: member.organizationId, - role: member.role, - }) - .from(member) - .where(eq(member.userId, referenceId)) - .limit(1) - - if (memberRecord && isOrgAdminRole(memberRecord.role)) { - return memberRecord.organizationId - } - - return null + return referencedOrganization?.id ?? null } /** diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index de7c5b5725e..7ce58ed8214 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -1,10 +1,9 @@ /** * Tests for getUserUsageLimit. * - * Org-scoped members carry a null `currentUsageLimit` by design, so a user - * whose subscription stops being org-scoped without a resync is left null. - * The limit read must self-heal that state to the plan/free base plus prepaid - * balance instead of failing closed and blocking every execution. + * Legacy membership syncs may leave a null personal usage limit. The limit + * read must recover the plan/free base plus prepaid balance, and subsequent + * subscription syncs must preserve independent personal and organization pools. * * @vitest-environment node */ @@ -25,12 +24,14 @@ afterAll(() => { const { mockGetFreeTierLimit, mockGetHighestPrioritySubscription, + mockGetHighestPriorityPersonalSubscription, mockGetPerUserMinimumLimit, mockHasPaidSubscriptionStatus, mockIsOrgScopedSubscription, } = vi.hoisted(() => ({ mockGetFreeTierLimit: vi.fn(), mockGetHighestPrioritySubscription: vi.fn(), + mockGetHighestPriorityPersonalSubscription: vi.fn(), mockGetPerUserMinimumLimit: vi.fn(), mockHasPaidSubscriptionStatus: vi.fn(), mockIsOrgScopedSubscription: vi.fn(), @@ -48,6 +49,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, + getHighestPriorityPersonalSubscription: mockGetHighestPriorityPersonalSubscription, })) vi.mock('@/lib/billing/core/access', () => ({ @@ -205,10 +207,49 @@ describe('syncUsageLimitsFromSubscription', () => { vi.clearAllMocks() resetDbChainMock() mockIsOrgScopedSubscription.mockReturnValue(false) + mockHasPaidSubscriptionStatus.mockImplementation((status: string) => status === 'active') + }) + + it.each([ + { plan: 'pro', minimum: 40 }, + { plan: 'enterprise', minimum: 0 }, + ])( + 'preserves a personal $plan cap when the user also belongs to an enterprise organization', + async ({ plan, minimum }) => { + const personalSubscription = { plan, referenceId: 'user-1', status: 'active' } + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(personalSubscription) + mockGetHighestPrioritySubscription.mockResolvedValue({ + plan: 'enterprise', + referenceId: 'org-1', + status: 'active', + }) + mockIsOrgScopedSubscription.mockReturnValue(true) + mockGetPerUserMinimumLimit.mockReturnValue(minimum) + dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80', creditBalance: '1' }]) + + await syncUsageLimitsFromSubscription('user-1') + + expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledExactlyOnceWith('user-1', { + onError: 'throw', + }) + expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() + expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(personalSubscription) + const update = dbChainMockFns.set.mock.calls[0]?.[0] + expect(update?.currentUsageLimit).not.toBeNull() + expect(JSON.stringify(update?.currentUsageLimit)).toContain('greatest') + } + ) + + it('does not reset a personal cap when its subscription lookup fails', async () => { + mockGetHighestPriorityPersonalSubscription.mockRejectedValueOnce(new Error('db unavailable')) + dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '80' }]) + + await expect(syncUsageLimitsFromSubscription('user-1')).rejects.toThrow('db unavailable') + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('raises a paid personal limit to plan base plus the exact prepaid balance', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(PRO_SUBSCRIPTION) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(PRO_SUBSCRIPTION) mockGetPerUserMinimumLimit.mockReturnValue(40) dbChainMockFns.limit.mockResolvedValueOnce([ { currentUsageLimit: '40', creditBalance: '0.005' }, @@ -224,7 +265,7 @@ describe('syncUsageLimitsFromSubscription', () => { }) it('restores free-tier base plus prepaid after a downgrade or org departure', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(null) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null) mockGetPerUserMinimumLimit.mockReturnValue(10) dbChainMockFns.limit.mockResolvedValueOnce([ { currentUsageLimit: null, creditBalance: '0.006' }, @@ -240,7 +281,7 @@ describe('syncUsageLimitsFromSubscription', () => { }) it('does not retain a higher paid custom cap after downgrade to free', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(null) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(null) mockGetPerUserMinimumLimit.mockReturnValue(10) dbChainMockFns.limit.mockResolvedValueOnce([ { currentUsageLimit: '100', creditBalance: '0.006' }, @@ -256,7 +297,7 @@ describe('syncUsageLimitsFromSubscription', () => { }) it('preserves a higher custom personal limit', async () => { - mockGetHighestPrioritySubscription.mockResolvedValue(PRO_SUBSCRIPTION) + mockGetHighestPriorityPersonalSubscription.mockResolvedValue(PRO_SUBSCRIPTION) mockGetPerUserMinimumLimit.mockReturnValue(40) dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '50', creditBalance: '1' }]) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 777b54586f9..7038083ec79 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -7,6 +7,7 @@ import { and, eq, isNull, sql } from 'drizzle-orm' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { + getHighestPriorityPersonalSubscription, getHighestPrioritySubscription, type HighestPrioritySubscription, } from '@/lib/billing/core/plan' @@ -449,13 +450,11 @@ export async function updateUserUsageLimit( * checks). Org-scoped subs return the organization limit; * personally-scoped subs return the individual user limit from userStats. * - * Org-scoped members carry a null `currentUsageLimit` by design (see - * `syncUsageLimitsFromSubscription`). A user whose subscription stops being - * org-scoped without a resync would otherwise stay null and fail closed on - * every execution, so a null limit self-heals to the plan/free base plus the - * exact prepaid balance here. The write-back is best-effort: a limit written - * concurrently wins, and a failed write still resolves to the fallback - * instead of blocking execution. + * Legacy organization membership syncs may have cleared the personal limit. + * A null limit self-heals to the personal plan/free base plus the exact prepaid + * balance here. The write-back is best-effort: a limit written concurrently + * wins, and a failed write still resolves to the fallback instead of blocking + * execution. */ export async function getUserUsageLimit( userId: string, @@ -576,11 +575,12 @@ export async function checkUsageStatus(userId: string): Promise<{ } /** - * Sync usage limits based on subscription changes + * Syncs the user's personal billing pool from their exact personal subscription. + * Organization subscriptions have a separate pool and never clear personal limits. */ export async function syncUsageLimitsFromSubscription(userId: string): Promise { const [subscription, currentUserStats] = await Promise.all([ - getHighestPrioritySubscription(userId), + getHighestPriorityPersonalSubscription(userId, { onError: 'throw' }), db.select(userStatsColumns).from(userStats).where(eq(userStats.userId, userId)).limit(1), ]) @@ -588,25 +588,6 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise ({ mockCreateOrganizationWithOwner: vi.fn(), mockGetPlanPricing: vi.fn(), @@ -22,6 +29,7 @@ const { mockAssertNoCompetingEnterpriseIssuance: vi.fn(), mockGetOrganizationIdForSubscriptionReference: vi.fn(), mockIsSubscriptionOrgScoped: vi.fn(), + mockSyncUsageLimitsFromSubscription: vi.fn(), })) vi.mock('@/lib/billing/core/billing', () => ({ @@ -34,7 +42,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({ })) vi.mock('@/lib/billing/core/usage', () => ({ - syncUsageLimitsFromSubscription: vi.fn(), + syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription, })) vi.mock('@/lib/billing/plan-helpers', () => ({ @@ -217,8 +225,47 @@ describe('syncSubscriptionUsageLimits', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + mockGetOrganizationIdForSubscriptionReference.mockResolvedValue(null) + }) + + it('syncs only the directly referenced personal subscriber', async () => { + queueTableRows(schemaMock.user, [{ id: 'user-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'other-member' }]) + + await syncSubscriptionUsageLimits({ + id: 'sub-personal', + plan: 'pro_25000', + referenceId: 'user-1', + status: 'active', + }) + + expect(mockGetOrganizationIdForSubscriptionReference).toHaveBeenCalledWith('user-1') + expect(mockSyncUsageLimitsFromSubscription).toHaveBeenCalledExactlyOnceWith('user-1') + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) + it.each(['team_6000', 'enterprise'])( + 'preserves personal member limits when syncing an organization %s subscription', + async (plan) => { + mockGetOrganizationIdForSubscriptionReference.mockResolvedValue('org-1') + mockGetPlanPricing.mockReturnValue({ basePrice: 25 }) + queueTableRows(schemaMock.member, [{ userId: 'member-1' }, { userId: 'member-2' }]) + + await syncSubscriptionUsageLimits({ + id: 'sub-organization', + plan, + referenceId: 'org-1', + status: 'active', + seats: 2, + }) + + expect(mockSyncUsageLimitsFromSubscription).not.toHaveBeenCalled() + expect(dbChainMockFns.from).not.toHaveBeenCalledWith(schemaMock.member) + expect(dbChainMockFns.update).not.toHaveBeenCalledWith(schemaMock.userStats) + } + ) + it('keeps prepaid headroom additive when a Team seat increase raises the base', async () => { mockGetOrganizationIdForSubscriptionReference.mockResolvedValue('org-1') mockGetPlanPricing.mockReturnValue({ basePrice: 25 }) diff --git a/apps/sim/lib/billing/organization.ts b/apps/sim/lib/billing/organization.ts index 6c51b8f9ec4..fc6219b250c 100644 --- a/apps/sim/lib/billing/organization.ts +++ b/apps/sim/lib/billing/organization.ts @@ -488,8 +488,8 @@ export async function ensureOrganizationForTeamSubscriptionTx( } /** - * Sync usage limits for subscription members - * Updates usage limits for all users associated with the subscription + * Syncs the billing pool directly referenced by the subscription. + * Organization membership does not select or reset a personal billing pool. */ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData) { try { @@ -514,7 +514,6 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData ) } - // Individual user subscription - sync their usage limits await syncUsageLimitsFromSubscription(subscription.referenceId) logger.info('Synced usage limits for individual user subscription', { @@ -523,11 +522,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData plan: subscription.plan, }) } else { - // Organization subscription - set org usage limit and sync member limits - // Set orgUsageLimit for any paid non-enterprise plan attached to - // the org. Enterprise is set via webhook with custom pricing. - // Min = (basePrice × seats) + prepaid balance. Prepaid credits are - // additive headroom and must not be absorbed by a later seat increase. + /** Enterprise has custom pricing; other paid pools retain prepaid headroom when seats increase. */ if (isPaid(subscription.plan) && !isEnterprise(subscription.plan)) { const { basePrice } = getPlanPricing(subscription.plan) const seats = subscription.seats || 1 @@ -554,40 +549,6 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData basePrice, }) } - - // Sync usage limits for all members - const members = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)) - - if (members.length > 0) { - for (const m of members) { - try { - await syncUsageLimitsFromSubscription(m.userId) - } catch (memberError) { - logger.error('Failed to sync usage limits for organization member', { - userId: m.userId, - organizationId, - subscriptionId: subscription.id, - error: memberError, - }) - } - } - - logger.info('Synced usage limits for organization members', { - organizationId, - memberCount: members.length, - subscriptionId: subscription.id, - plan: subscription.plan, - }) - - /** - * Storage is workspace-routed, not membership-routed. Workspace payer - * changes transfer the workspace's own durable byte ledger atomically; - * subscription sync must not move an account-wide user counter. - */ - } } } catch (error) { logger.error('Failed to sync subscription usage limits', { diff --git a/apps/sim/lib/billing/webhooks/subscription-usage.test.ts b/apps/sim/lib/billing/webhooks/subscription-usage.test.ts new file mode 100644 index 00000000000..38930df3a9a --- /dev/null +++ b/apps/sim/lib/billing/webhooks/subscription-usage.test.ts @@ -0,0 +1,151 @@ +/** @vitest-environment node */ +import { stripe } from '@better-auth/stripe' +import { createMockStripeEvent, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { betterAuth } from 'better-auth' +import { memoryAdapter } from 'better-auth/adapters/memory' +import Stripe from 'stripe' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSyncSubscriptionUsageLimits } = vi.hoisted(() => ({ + mockSyncSubscriptionUsageLimits: vi.fn(), +})) + +vi.mock('@/lib/billing/organization', () => ({ + syncSubscriptionUsageLimits: mockSyncSubscriptionUsageLimits, +})) + +import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage' + +const persistedSubscription = { + id: 'subscription-1', + referenceId: 'org-1', + plan: 'team', + status: 'active', + seats: 2, +} + +const updateEvent = () => + createMockStripeEvent('customer.subscription.updated', { + id: 'sub_stripe', + object: 'subscription', + customer: 'cus_1', + status: 'active', + cancel_at_period_end: false, + metadata: {}, + items: { + data: [ + { + id: 'si_1', + quantity: 2, + current_period_start: 1788220800, + current_period_end: 1790812800, + price: { id: 'price_team', recurring: { interval: 'month' } }, + }, + ], + }, + }) + +describe('handleSubscriptionUsageUpdate', () => { + beforeEach(() => { + resetDbChainMock() + mockSyncSubscriptionUsageLimits.mockReset().mockResolvedValue(undefined) + dbChainMockFns.limit.mockResolvedValue([persistedSubscription]) + }) + + afterEach(resetDbChainMock) + + it('uses the persisted payer reference after subscription callbacks have rehomed it', async () => { + await handleSubscriptionUsageUpdate(updateEvent()) + + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'eq', + left: schemaMock.subscription.stripeSubscriptionId, + right: 'sub_stripe', + }) + expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledExactlyOnceWith(persistedSubscription) + }) + + it('ignores other event types', async () => { + await handleSubscriptionUsageUpdate(createMockStripeEvent('customer.subscription.created', {})) + + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled() + }) + + it('ignores subscriptions that are not tracked locally', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await handleSubscriptionUsageUpdate(updateEvent()) + + expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled() + }) + + it.each(['lookup', 'reconciliation'])( + 'returns a failed webhook response on %s failure and reconciles on redelivery', + async (failure) => { + const onSubscriptionUpdate = vi.fn() + const stripeClient = new Stripe('sk_test_placeholder') + const webhookSecret = 'whsec_subscription_usage_test' + const provider = betterAuth({ + baseURL: 'https://sim.test', + secret: 'isolated-stripe-webhook-test-secret-123456789', + database: memoryAdapter({ + user: [], + session: [], + account: [], + verification: [], + subscription: [ + { + ...persistedSubscription, + stripeCustomerId: 'cus_1', + stripeSubscriptionId: 'sub_stripe', + }, + ], + }), + logger: { disabled: true }, + plugins: [ + stripe({ + stripeClient, + stripeWebhookSecret: webhookSecret, + subscription: { + enabled: true, + plans: [{ name: 'team', priceId: 'price_team' }], + onSubscriptionUpdate, + }, + onEvent: handleSubscriptionUsageUpdate, + }), + ], + }) + const payload = JSON.stringify(updateEvent()) + const signature = stripeClient.webhooks.generateTestHeaderString({ + payload, + secret: webhookSecret, + }) + const deliver = () => + provider.handler( + new Request('https://sim.test/api/auth/stripe/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'stripe-signature': signature }, + body: payload, + }) + ) + + if (failure === 'lookup') { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + } else { + mockSyncSubscriptionUsageLimits.mockRejectedValueOnce(new Error('database unavailable')) + } + + const failed = await deliver() + expect(failed.ok).toBe(false) + expect(await failed.json()).toMatchObject({ code: 'STRIPE_WEBHOOK_ERROR' }) + expect(onSubscriptionUpdate).toHaveBeenCalledOnce() + + const retried = await deliver() + expect(retried.status).toBe(200) + expect(await retried.json()).toEqual({ success: true }) + expect(onSubscriptionUpdate).toHaveBeenCalledTimes(2) + expect(mockSyncSubscriptionUsageLimits).toHaveBeenLastCalledWith(persistedSubscription) + } + ) +}) diff --git a/apps/sim/lib/billing/webhooks/subscription-usage.ts b/apps/sim/lib/billing/webhooks/subscription-usage.ts new file mode 100644 index 00000000000..fd1a229a646 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/subscription-usage.ts @@ -0,0 +1,30 @@ +import { db } from '@sim/db' +import { subscription } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type Stripe from 'stripe' +import { syncSubscriptionUsageLimits } from '@/lib/billing/organization' + +/** + * Reconciles usage limits through the Stripe plugin's retryable onEvent hook. + * Read the persisted reference after subscription callbacks may have moved it + * to an organization. Callback exceptions alone are swallowed by the plugin. + */ +export async function handleSubscriptionUsageUpdate(event: Stripe.Event): Promise { + if (event.type !== 'customer.subscription.updated') return + + const [persistedSubscription] = await db + .select({ + id: subscription.id, + referenceId: subscription.referenceId, + plan: subscription.plan, + status: subscription.status, + seats: subscription.seats, + }) + .from(subscription) + .where(eq(subscription.stripeSubscriptionId, event.data.object.id)) + .limit(1) + + if (!persistedSubscription) return + + await syncSubscriptionUsageLimits(persistedSubscription) +}