Skip to content

Commit dd2b3f1

Browse files
committed
fix(billing): retry subscription limit reconciliation through webhook events
1 parent c293af2 commit dd2b3f1

3 files changed

Lines changed: 183 additions & 10 deletions

File tree

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': {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/** @vitest-environment node */
2+
import { stripe } from '@better-auth/stripe'
3+
import { createMockStripeEvent, dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
4+
import { betterAuth } from 'better-auth'
5+
import { memoryAdapter } from 'better-auth/adapters/memory'
6+
import Stripe from 'stripe'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockSyncSubscriptionUsageLimits } = vi.hoisted(() => ({
10+
mockSyncSubscriptionUsageLimits: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/billing/organization', () => ({
14+
syncSubscriptionUsageLimits: mockSyncSubscriptionUsageLimits,
15+
}))
16+
17+
import { handleSubscriptionUsageUpdate } from '@/lib/billing/webhooks/subscription-usage'
18+
19+
const persistedSubscription = {
20+
id: 'subscription-1',
21+
referenceId: 'org-1',
22+
plan: 'team',
23+
status: 'active',
24+
seats: 2,
25+
}
26+
27+
const updateEvent = () =>
28+
createMockStripeEvent('customer.subscription.updated', {
29+
id: 'sub_stripe',
30+
object: 'subscription',
31+
customer: 'cus_1',
32+
status: 'active',
33+
cancel_at_period_end: false,
34+
metadata: {},
35+
items: {
36+
data: [
37+
{
38+
id: 'si_1',
39+
quantity: 2,
40+
current_period_start: 1788220800,
41+
current_period_end: 1790812800,
42+
price: { id: 'price_team', recurring: { interval: 'month' } },
43+
},
44+
],
45+
},
46+
})
47+
48+
describe('handleSubscriptionUsageUpdate', () => {
49+
beforeEach(() => {
50+
resetDbChainMock()
51+
mockSyncSubscriptionUsageLimits.mockReset().mockResolvedValue(undefined)
52+
dbChainMockFns.limit.mockResolvedValue([persistedSubscription])
53+
})
54+
55+
afterEach(resetDbChainMock)
56+
57+
it('uses the persisted payer reference after subscription callbacks have rehomed it', async () => {
58+
await handleSubscriptionUsageUpdate(updateEvent())
59+
60+
expect(dbChainMockFns.where).toHaveBeenCalledWith({
61+
type: 'eq',
62+
left: schemaMock.subscription.stripeSubscriptionId,
63+
right: 'sub_stripe',
64+
})
65+
expect(mockSyncSubscriptionUsageLimits).toHaveBeenCalledExactlyOnceWith(persistedSubscription)
66+
})
67+
68+
it('ignores other event types', async () => {
69+
await handleSubscriptionUsageUpdate(createMockStripeEvent('customer.subscription.created', {}))
70+
71+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
72+
expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled()
73+
})
74+
75+
it('ignores subscriptions that are not tracked locally', async () => {
76+
dbChainMockFns.limit.mockResolvedValueOnce([])
77+
78+
await handleSubscriptionUsageUpdate(updateEvent())
79+
80+
expect(mockSyncSubscriptionUsageLimits).not.toHaveBeenCalled()
81+
})
82+
83+
it.each(['lookup', 'reconciliation'])(
84+
'returns a failed webhook response on %s failure and reconciles on redelivery',
85+
async (failure) => {
86+
const onSubscriptionUpdate = vi.fn()
87+
const stripeClient = new Stripe('sk_test_placeholder')
88+
const webhookSecret = 'whsec_subscription_usage_test'
89+
const provider = betterAuth({
90+
baseURL: 'https://sim.test',
91+
secret: 'isolated-stripe-webhook-test-secret-123456789',
92+
database: memoryAdapter({
93+
user: [],
94+
session: [],
95+
account: [],
96+
verification: [],
97+
subscription: [
98+
{
99+
...persistedSubscription,
100+
stripeCustomerId: 'cus_1',
101+
stripeSubscriptionId: 'sub_stripe',
102+
},
103+
],
104+
}),
105+
logger: { disabled: true },
106+
plugins: [
107+
stripe({
108+
stripeClient,
109+
stripeWebhookSecret: webhookSecret,
110+
subscription: {
111+
enabled: true,
112+
plans: [{ name: 'team', priceId: 'price_team' }],
113+
onSubscriptionUpdate,
114+
},
115+
onEvent: handleSubscriptionUsageUpdate,
116+
}),
117+
],
118+
})
119+
const payload = JSON.stringify(updateEvent())
120+
const signature = stripeClient.webhooks.generateTestHeaderString({
121+
payload,
122+
secret: webhookSecret,
123+
})
124+
const deliver = () =>
125+
provider.handler(
126+
new Request('https://sim.test/api/auth/stripe/webhook', {
127+
method: 'POST',
128+
headers: { 'Content-Type': 'application/json', 'stripe-signature': signature },
129+
body: payload,
130+
})
131+
)
132+
133+
if (failure === 'lookup') {
134+
dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable'))
135+
} else {
136+
mockSyncSubscriptionUsageLimits.mockRejectedValueOnce(new Error('database unavailable'))
137+
}
138+
139+
const failed = await deliver()
140+
expect(failed.ok).toBe(false)
141+
expect(await failed.json()).toMatchObject({ code: 'STRIPE_WEBHOOK_ERROR' })
142+
expect(onSubscriptionUpdate).toHaveBeenCalledOnce()
143+
144+
const retried = await deliver()
145+
expect(retried.status).toBe(200)
146+
expect(await retried.json()).toEqual({ success: true })
147+
expect(onSubscriptionUpdate).toHaveBeenCalledTimes(2)
148+
expect(mockSyncSubscriptionUsageLimits).toHaveBeenLastCalledWith(persistedSubscription)
149+
}
150+
)
151+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { db } from '@sim/db'
2+
import { subscription } from '@sim/db/schema'
3+
import { eq } from 'drizzle-orm'
4+
import type Stripe from 'stripe'
5+
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
6+
7+
/**
8+
* Reconciles usage limits through the Stripe plugin's retryable onEvent hook.
9+
* Read the persisted reference after subscription callbacks may have moved it
10+
* to an organization. Callback exceptions alone are swallowed by the plugin.
11+
*/
12+
export async function handleSubscriptionUsageUpdate(event: Stripe.Event): Promise<void> {
13+
if (event.type !== 'customer.subscription.updated') return
14+
15+
const [persistedSubscription] = await db
16+
.select({
17+
id: subscription.id,
18+
referenceId: subscription.referenceId,
19+
plan: subscription.plan,
20+
status: subscription.status,
21+
seats: subscription.seats,
22+
})
23+
.from(subscription)
24+
.where(eq(subscription.stripeSubscriptionId, event.data.object.id))
25+
.limit(1)
26+
27+
if (!persistedSubscription) return
28+
29+
await syncSubscriptionUsageLimits(persistedSubscription)
30+
}

0 commit comments

Comments
 (0)