Skip to content

Commit e4cc7da

Browse files
committed
fix(billing): don't let a post-commit usage-limit sync failure suppress the seat audit
Cursor Bugbot flagged: reconcileOrganizationSeats committed the seat change and Stripe outbox enqueue in a transaction, then called syncSubscriptionUsageLimits outside it before recording the audit/ PostHog events. A thrown sync left a genuinely-changed seat count with no ORG_SEAT_PROVISIONED/DEPROVISIONED trail. Wrap the sync in its own try/catch (log + continue) so the events always fire for a committed change, matching the fire-and-forget instrumentation pattern used elsewhere in this PR.
1 parent 59f1d11 commit e4cc7da

2 files changed

Lines changed: 42 additions & 7 deletions

File tree

apps/sim/lib/billing/organizations/seats.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { auditMock } from '@sim/testing'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56

67
const { mockSyncSubscriptionUsageLimits, enqueueMock, setMock, queryQueue, mockFeatureFlags } =
@@ -58,6 +59,8 @@ vi.mock('@/lib/core/config/env-flags', () => ({
5859
},
5960
}))
6061

62+
vi.mock('@sim/audit', () => auditMock)
63+
6164
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
6265

6366
const teamSub = {
@@ -101,6 +104,27 @@ describe('reconcileOrganizationSeats', () => {
101104
)
102105
})
103106

107+
it('still records the seat audit when the post-commit usage-limit sync fails', async () => {
108+
queryQueue.value = [[teamSub], [{ value: 2 }]]
109+
mockSyncSubscriptionUsageLimits.mockRejectedValueOnce(new Error('sync unavailable'))
110+
111+
const result = await reconcileOrganizationSeats({
112+
organizationId: 'org-1',
113+
reason: 'member-accepted-invite',
114+
actorId: 'user-1',
115+
})
116+
117+
expect(result.changed).toBe(true)
118+
expect(setMock).toHaveBeenCalledWith({ seats: 2 })
119+
expect(auditMock.recordAudit).toHaveBeenCalledWith(
120+
expect.objectContaining({
121+
actorId: 'user-1',
122+
action: auditMock.AuditAction.ORG_SEAT_PROVISIONED,
123+
resourceId: 'org-1',
124+
})
125+
)
126+
})
127+
104128
it('reduces seats to the member count on removal', async () => {
105129
queryQueue.value = [[{ ...teamSub, seats: 3 }], [{ value: 2 }]]
106130

apps/sim/lib/billing/organizations/seats.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,24 @@ export async function reconcileOrganizationSeats({
136136
return { changed: false, previousSeats: outcome.seats, seats: outcome.seats }
137137
}
138138

139-
await syncSubscriptionUsageLimits({
140-
id: outcome.sync.id,
141-
plan: outcome.sync.plan,
142-
referenceId: organizationId,
143-
status: outcome.sync.status,
144-
seats: outcome.seats,
145-
})
139+
try {
140+
await syncSubscriptionUsageLimits({
141+
id: outcome.sync.id,
142+
plan: outcome.sync.plan,
143+
referenceId: organizationId,
144+
status: outcome.sync.status,
145+
seats: outcome.seats,
146+
})
147+
} catch (error) {
148+
// Seats already committed in the transaction above; a failure here must not
149+
// suppress the audit/analytics events below for a change that already happened.
150+
logger.error('Failed to sync usage limits after seat reconciliation', {
151+
organizationId,
152+
previousSeats: outcome.previousSeats,
153+
seats: outcome.seats,
154+
error,
155+
})
156+
}
146157

147158
logger.info('Reconciled organization seats to member count', {
148159
organizationId,

0 commit comments

Comments
 (0)