Skip to content

Commit 7b32a85

Browse files
improvement(seats): auto purchase seats on invitations into workspace (#4857)
* improvement(seats): auto purchase seats on invitations into workspace * improve sampling for seat drift reconciler * address comments
1 parent b448f77 commit 7b32a85

46 files changed

Lines changed: 2591 additions & 1063 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Tests for the billing seat reconciliation cron route.
3+
*
4+
* @vitest-environment node
5+
*/
6+
import { createMockRequest } from '@sim/testing'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockVerifyCronAuth, mockReconcileTeamSeatDrift, mockFindDeadLetteredEvents } = vi.hoisted(
10+
() => ({
11+
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
12+
mockReconcileTeamSeatDrift: vi.fn(),
13+
mockFindDeadLetteredEvents: vi.fn(),
14+
})
15+
)
16+
17+
vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth }))
18+
vi.mock('@/lib/billing/organizations/seat-drift', () => ({
19+
reconcileTeamSeatDrift: mockReconcileTeamSeatDrift,
20+
}))
21+
vi.mock('@/lib/core/outbox/service', () => ({ findDeadLetteredEvents: mockFindDeadLetteredEvents }))
22+
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
23+
OUTBOX_EVENT_TYPES: {
24+
STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
25+
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
26+
},
27+
}))
28+
29+
import { GET } from './route'
30+
31+
function createRequest() {
32+
return createMockRequest(
33+
'GET',
34+
undefined,
35+
{},
36+
'http://localhost:3000/api/cron/reconcile-billing-seats'
37+
)
38+
}
39+
40+
describe('reconcile-billing-seats cron route', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
mockVerifyCronAuth.mockReturnValue(null)
44+
mockReconcileTeamSeatDrift.mockResolvedValue({ drifted: 0, reconciled: 0 })
45+
mockFindDeadLetteredEvents.mockResolvedValue([])
46+
})
47+
48+
it('returns the auth error when cron auth fails', async () => {
49+
mockVerifyCronAuth.mockReturnValueOnce(new Response(null, { status: 401 }) as never)
50+
51+
const response = await GET(createRequest())
52+
53+
expect(response.status).toBe(401)
54+
expect(mockReconcileTeamSeatDrift).not.toHaveBeenCalled()
55+
})
56+
57+
it('runs the drift sweep and reports dead-lettered billing syncs', async () => {
58+
mockReconcileTeamSeatDrift.mockResolvedValue({ drifted: 2, reconciled: 1 })
59+
mockFindDeadLetteredEvents.mockResolvedValue([
60+
{
61+
id: 'evt-1',
62+
eventType: 'stripe.sync-subscription-seats',
63+
payload: { subscriptionId: 'sub-1' },
64+
lastError: 'card declined',
65+
},
66+
])
67+
68+
const response = await GET(createRequest())
69+
70+
expect(response.status).toBe(200)
71+
const data = await response.json()
72+
expect(data).toMatchObject({
73+
success: true,
74+
drift: { drifted: 2, reconciled: 1 },
75+
deadLetteredBillingSyncs: 1,
76+
})
77+
expect(mockFindDeadLetteredEvents).toHaveBeenCalledWith([
78+
'stripe.sync-subscription-seats',
79+
'stripe.sync-cancel-at-period-end',
80+
])
81+
})
82+
83+
it('returns 500 when the sweep throws', async () => {
84+
mockReconcileTeamSeatDrift.mockRejectedValueOnce(new Error('boom'))
85+
86+
const response = await GET(createRequest())
87+
88+
expect(response.status).toBe(500)
89+
const data = await response.json()
90+
expect(data.success).toBe(false)
91+
})
92+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { verifyCronAuth } from '@/lib/auth/internal'
5+
import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift'
6+
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
7+
import { findDeadLetteredEvents } from '@/lib/core/outbox/service'
8+
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
11+
const logger = createLogger('BillingSeatReconcileCron')
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const BILLING_SYNC_EVENT_TYPES = [
16+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
17+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
18+
]
19+
20+
/**
21+
* Periodic billing-seat reconciliation. Self-heals Team organizations whose
22+
* stored seat count drifted from their member count, and reports any
23+
* dead-lettered Stripe seat/cancel sync events so a member who has access but
24+
* whose seat charge never synced is surfaced for manual remediation rather than
25+
* silently under-billed.
26+
*
27+
* Scheduled in helm/sim/values.yaml under cronjobs.jobs.reconcileBillingSeats.
28+
*/
29+
export const GET = withRouteHandler(async (request: NextRequest) => {
30+
const requestId = generateRequestId()
31+
32+
const authError = verifyCronAuth(request, 'Billing seat reconciliation')
33+
if (authError) {
34+
return authError
35+
}
36+
37+
try {
38+
const drift = await reconcileTeamSeatDrift()
39+
40+
const deadLettered = await findDeadLetteredEvents(BILLING_SYNC_EVENT_TYPES)
41+
if (deadLettered.length > 0) {
42+
logger.error(
43+
'Dead-lettered billing sync events require manual remediation — a billing state change (seat charge or cancellation) never reached Stripe',
44+
{
45+
requestId,
46+
count: deadLettered.length,
47+
events: deadLettered.map((event) => ({
48+
id: event.id,
49+
eventType: event.eventType,
50+
subscriptionId: (event.payload as { subscriptionId?: string } | null)?.subscriptionId,
51+
lastError: event.lastError,
52+
})),
53+
}
54+
)
55+
}
56+
57+
logger.info('Billing seat reconciliation completed', {
58+
requestId,
59+
...drift,
60+
deadLetteredBillingSyncs: deadLettered.length,
61+
})
62+
63+
return NextResponse.json({
64+
success: true,
65+
requestId,
66+
drift,
67+
deadLetteredBillingSyncs: deadLettered.length,
68+
})
69+
} catch (error) {
70+
logger.error('Billing seat reconciliation failed', {
71+
requestId,
72+
error: toError(error).message,
73+
})
74+
return NextResponse.json(
75+
{ success: false, requestId, error: toError(error).message },
76+
{ status: 500 }
77+
)
78+
}
79+
})

apps/sim/app/api/invitations/[id]/accept/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const POST = withRouteHandler(
3838
'email-mismatch': 403,
3939
'already-in-organization': 409,
4040
'no-seats-available': 400,
41+
'upgrade-required': 402,
4142
'server-error': 500,
4243
}
4344
const status = statusMap[result.kind] ?? 500

apps/sim/app/api/organizations/[id]/invitations/route.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
} from '@/lib/api/contracts/organization'
1111
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1212
import { getSession } from '@/lib/auth'
13+
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
14+
import { isEnterprise } from '@/lib/billing/plan-helpers'
1315
import {
1416
validateBulkInvitations,
1517
validateSeatAvailability,
@@ -287,8 +289,12 @@ export const POST = withRouteHandler(
287289
)
288290
}
289291

290-
const seatValidation = await validateSeatAvailability(organizationId, emailsToInvite.length)
291-
if (!seatValidation.canInvite) {
292+
const orgSubscription = await getOrganizationSubscription(organizationId)
293+
const enforceFixedSeats = !!orgSubscription && isEnterprise(orgSubscription.plan)
294+
const seatValidation = enforceFixedSeats
295+
? await validateSeatAvailability(organizationId, emailsToInvite.length)
296+
: null
297+
if (seatValidation && !seatValidation.canInvite) {
292298
return NextResponse.json(
293299
{
294300
error: seatValidation.reason,
@@ -394,11 +400,15 @@ export const POST = withRouteHandler(
394400
(email) => !quickValidateEmail(email.trim().toLowerCase()).isValid
395401
),
396402
workspaceGrantsPerInvite: validGrants.length,
397-
seatInfo: {
398-
seatsUsed: seatValidation.currentSeats + sentInvitations.length,
399-
maxSeats: seatValidation.maxSeats,
400-
availableSeats: seatValidation.availableSeats - sentInvitations.length,
401-
},
403+
...(seatValidation
404+
? {
405+
seatInfo: {
406+
seatsUsed: seatValidation.currentSeats + sentInvitations.length,
407+
maxSeats: seatValidation.maxSeats,
408+
availableSeats: seatValidation.availableSeats - sentInvitations.length,
409+
},
410+
}
411+
: {}),
402412
}
403413

404414
if (failedInvitations.length > 0 && sentInvitations.length === 0) {

apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@ import { member, user, userStats } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { and, eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
7-
import {
8-
removeOrganizationMemberQuerySchema,
9-
updateOrganizationMemberRoleContract,
10-
} from '@/lib/api/contracts/organization'
7+
import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organization'
118
import { parseRequest } from '@/lib/api/server'
129
import { getSession } from '@/lib/auth'
1310
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
@@ -17,7 +14,7 @@ import {
1714
removeExternalUserFromOrganizationWorkspaces,
1815
removeUserFromOrganization,
1916
} from '@/lib/billing/organizations/membership'
20-
import { reduceOrganizationSeatsByOne } from '@/lib/billing/organizations/seats'
17+
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
2118
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2219

2320
const logger = createLogger('OrganizationMemberAPI')
@@ -292,12 +289,6 @@ export const DELETE = withRouteHandler(
292289
}
293290

294291
const { id: organizationId, memberId: targetUserId } = await params
295-
const queryResult = removeOrganizationMemberQuerySchema.safeParse(
296-
Object.fromEntries(request.nextUrl.searchParams.entries())
297-
)
298-
const shouldReduceSeats = queryResult.success
299-
? queryResult.data.shouldReduceSeats === true
300-
: false
301292

302293
const userMember = await db
303294
.select()
@@ -418,25 +409,22 @@ export const DELETE = withRouteHandler(
418409
return NextResponse.json({ error: result.error }, { status: 500 })
419410
}
420411

421-
let seatReduction: Awaited<ReturnType<typeof reduceOrganizationSeatsByOne>> | null = null
422-
if (shouldReduceSeats && session.user.id !== targetUserId) {
423-
try {
424-
seatReduction = await reduceOrganizationSeatsByOne({
425-
organizationId,
426-
actorUserId: session.user.id,
427-
removedUserId: targetUserId,
428-
})
429-
} catch (seatError) {
430-
logger.error('Failed to reduce seats after member removal', {
431-
organizationId,
432-
removedMemberId: targetUserId,
433-
removedBy: session.user.id,
434-
error: seatError,
435-
})
436-
seatReduction = {
437-
reduced: false,
438-
reason: 'Failed to reduce seats after member removal',
439-
}
412+
let seatReduction: Awaited<ReturnType<typeof reconcileOrganizationSeats>> | null = null
413+
try {
414+
seatReduction = await reconcileOrganizationSeats({
415+
organizationId,
416+
reason: 'member-removed',
417+
})
418+
} catch (seatError) {
419+
logger.error('Failed to reduce seats after member removal', {
420+
organizationId,
421+
removedMemberId: targetUserId,
422+
removedBy: session.user.id,
423+
error: seatError,
424+
})
425+
seatReduction = {
426+
changed: false,
427+
reason: 'Failed to reduce seats after member removal',
440428
}
441429
}
442430

apps/sim/app/api/organizations/[id]/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ export const GET = withRouteHandler(
119119
/**
120120
* PUT /api/organizations/[id]
121121
* Update organization settings (name, slug, logo)
122-
* Note: For seat updates, use PUT /api/organizations/[id]/seats instead
123122
*/
124123
export const PUT = withRouteHandler(
125124
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {

0 commit comments

Comments
 (0)