Skip to content

Commit 825328f

Browse files
committed
add outbox for seat reduction
1 parent 7ac4739 commit 825328f

6 files changed

Lines changed: 296 additions & 137 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,11 @@ export const DELETE = withRouteHandler(
410410
let seatReduction: Awaited<ReturnType<typeof reduceOrganizationSeatsByOne>> | null = null
411411
if (shouldReduceSeats && session.user.id !== targetUserId) {
412412
try {
413-
seatReduction = await reduceOrganizationSeatsByOne(organizationId, session.user.id)
413+
seatReduction = await reduceOrganizationSeatsByOne({
414+
organizationId,
415+
actorUserId: session.user.id,
416+
removedUserId: targetUserId,
417+
})
414418
} catch (seatError) {
415419
logger.error('Failed to reduce seats after member removal', {
416420
organizationId,

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface RemoveMemberDialogProps {
1212
open: boolean
1313
memberName: string
1414
shouldReduceSeats: boolean
15+
canReduceSeats?: boolean
1516
isSelfRemoval?: boolean
1617
isExternalRemoval?: boolean
1718
isSubmitting?: boolean
@@ -26,6 +27,7 @@ export function RemoveMemberDialog({
2627
open,
2728
memberName,
2829
shouldReduceSeats,
30+
canReduceSeats = true,
2931
error,
3032
onOpenChange,
3133
onShouldReduceSeatsChange,
@@ -66,7 +68,7 @@ export function RemoveMemberDialog({
6668
This action cannot be undone.
6769
</p>
6870

69-
{!isSelfRemoval && !isExternalRemoval && (
71+
{!isSelfRemoval && !isExternalRemoval && canReduceSeats && (
7072
<div className='mt-4'>
7173
<div className='flex items-center gap-2'>
7274
<Checkbox
@@ -100,7 +102,9 @@ export function RemoveMemberDialog({
100102
<Button
101103
variant='destructive'
102104
disabled={isSubmitting}
103-
onClick={() => onConfirmRemove(isExternalRemoval ? false : shouldReduceSeats)}
105+
onClick={() =>
106+
onConfirmRemove(isExternalRemoval || !canReduceSeats ? false : shouldReduceSeats)
107+
}
104108
>
105109
{isSelfRemoval ? 'Leave Organization' : 'Remove'}
106110
</Button>

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Skeleton, type TagItem } from '@/components/emcn'
66
import { useSession } from '@/lib/auth/auth-client'
77
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
88
import { getPlanTierCredits, getPlanTierDollars } from '@/lib/billing/plan-helpers'
9-
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
9+
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
1010
import { getBaseUrl } from '@/lib/core/utils/urls'
1111
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
1212
import {
@@ -106,6 +106,7 @@ export function TeamManagement() {
106106
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
107107
const totalSeats = organizationBillingData?.data?.totalSeats ?? 0
108108
const usedSeats = organizationBillingData?.data?.usedSeats ?? 0
109+
const canReduceSubscriptionSeats = Boolean(subscriptionData && checkTeamPlan(subscriptionData))
109110

110111
useEffect(() => {
111112
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
@@ -503,6 +504,7 @@ export function TeamManagement() {
503504
open={removeMemberDialog.open}
504505
memberName={removeMemberDialog.memberName}
505506
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
507+
canReduceSeats={canReduceSubscriptionSeats}
506508
isSelfRemoval={removeMemberDialog.isSelfRemoval}
507509
isExternalRemoval={removeMemberDialog.isExternalRemoval}
508510
isSubmitting={removeMemberMutation.isPending}

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

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,7 @@ export async function transferOrganizationOwnership(
14291429

14301430
const [orgSub] = await tx
14311431
.select({
1432+
id: subscriptionTable.id,
14321433
stripeCustomerId: subscriptionTable.stripeCustomerId,
14331434
})
14341435
.from(subscriptionTable)
@@ -1441,20 +1442,10 @@ export async function transferOrganizationOwnership(
14411442
.limit(1)
14421443

14431444
if (orgSub?.stripeCustomerId) {
1444-
const [newOwnerUser] = await tx
1445-
.select({ email: user.email, name: user.name })
1446-
.from(user)
1447-
.where(eq(user.id, newOwnerUserId))
1448-
.limit(1)
1449-
1450-
if (newOwnerUser?.email) {
1451-
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT, {
1452-
stripeCustomerId: orgSub.stripeCustomerId,
1453-
email: newOwnerUser.email,
1454-
name: newOwnerUser.name ?? undefined,
1455-
reason: 'ownership-transfer',
1456-
})
1457-
}
1445+
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT, {
1446+
subscriptionId: orgSub.id,
1447+
reason: 'ownership-transfer',
1448+
})
14581449
}
14591450
})
14601451

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

Lines changed: 91 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@ import { createLogger } from '@sim/logger'
44
import { and, count, eq, gt, inArray, ne } from 'drizzle-orm'
55
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
66
import { isTeam } from '@/lib/billing/plan-helpers'
7-
import { requireStripeClient } from '@/lib/billing/stripe-client'
8-
import {
9-
hasUsableSubscriptionStatus,
10-
USABLE_SUBSCRIPTION_STATUSES,
11-
} from '@/lib/billing/subscriptions/utils'
12-
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
7+
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
8+
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
139
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
10+
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
1411

1512
const logger = createLogger('OrganizationSeats')
1613

@@ -19,131 +16,115 @@ export interface ReduceOrganizationSeatsResult {
1916
previousSeats?: number
2017
seats?: number
2118
reason?: string
19+
outboxEventId?: string
2220
}
2321

24-
export async function reduceOrganizationSeatsByOne(
25-
organizationId: string,
26-
userId: string
27-
): Promise<ReduceOrganizationSeatsResult> {
22+
interface ReduceOrganizationSeatsByOneParams {
23+
organizationId: string
24+
actorUserId: string
25+
removedUserId: string
26+
}
27+
28+
export async function reduceOrganizationSeatsByOne({
29+
organizationId,
30+
actorUserId,
31+
removedUserId,
32+
}: ReduceOrganizationSeatsByOneParams): Promise<ReduceOrganizationSeatsResult> {
2833
if (!isBillingEnabled) {
2934
return { reduced: false, reason: 'Billing is not enabled' }
3035
}
3136

32-
const [orgSubscription] = await db
33-
.select()
34-
.from(subscription)
35-
.where(
36-
and(
37-
eq(subscription.referenceId, organizationId),
38-
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
37+
return db.transaction(async (tx) => {
38+
const [orgSubscription] = await tx
39+
.select()
40+
.from(subscription)
41+
.where(
42+
and(
43+
eq(subscription.referenceId, organizationId),
44+
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
45+
)
3946
)
40-
)
41-
.limit(1)
47+
.for('update')
48+
.limit(1)
4249

43-
if (!orgSubscription) {
44-
return { reduced: false, reason: 'No active subscription found' }
45-
}
50+
if (!orgSubscription) {
51+
return { reduced: false, reason: 'No active subscription found' }
52+
}
4653

47-
if (await isOrganizationBillingBlocked(organizationId)) {
48-
return { reduced: false, reason: 'An active subscription is required' }
49-
}
54+
if (await isOrganizationBillingBlocked(organizationId)) {
55+
return { reduced: false, reason: 'An active subscription is required' }
56+
}
5057

51-
if (!isTeam(orgSubscription.plan)) {
52-
return { reduced: false, reason: 'Seat changes are only available for Team plans' }
53-
}
58+
if (!isTeam(orgSubscription.plan)) {
59+
return { reduced: false, reason: 'Seat changes are only available for Team plans' }
60+
}
5461

55-
if (!orgSubscription.stripeSubscriptionId) {
56-
return { reduced: false, reason: 'No Stripe subscription found for this organization' }
57-
}
62+
if (!orgSubscription.stripeSubscriptionId) {
63+
return { reduced: false, reason: 'No Stripe subscription found for this organization' }
64+
}
5865

59-
const currentSeats = orgSubscription.seats || 1
60-
if (currentSeats <= 1) {
61-
return {
62-
reduced: false,
63-
previousSeats: currentSeats,
64-
seats: currentSeats,
65-
reason: 'Minimum 1 seat required',
66+
const currentSeats = orgSubscription.seats || 1
67+
if (currentSeats <= 1) {
68+
return {
69+
reduced: false,
70+
previousSeats: currentSeats,
71+
seats: currentSeats,
72+
reason: 'Minimum 1 seat required',
73+
}
6674
}
67-
}
6875

69-
const [memberCountRow] = await db
70-
.select({ count: count() })
71-
.from(member)
72-
.where(eq(member.organizationId, organizationId))
73-
74-
const [pendingCountRow] = await db
75-
.select({ count: count() })
76-
.from(invitation)
77-
.where(
78-
and(
79-
eq(invitation.organizationId, organizationId),
80-
eq(invitation.status, 'pending'),
81-
ne(invitation.membershipIntent, 'external'),
82-
gt(invitation.expiresAt, new Date())
76+
const [memberCountRow] = await tx
77+
.select({ count: count() })
78+
.from(member)
79+
.where(eq(member.organizationId, organizationId))
80+
81+
const [pendingCountRow] = await tx
82+
.select({ count: count() })
83+
.from(invitation)
84+
.where(
85+
and(
86+
eq(invitation.organizationId, organizationId),
87+
eq(invitation.status, 'pending'),
88+
ne(invitation.membershipIntent, 'external'),
89+
gt(invitation.expiresAt, new Date())
90+
)
8391
)
84-
)
8592

86-
const occupiedSeats = (memberCountRow?.count ?? 0) + (pendingCountRow?.count ?? 0)
87-
const nextSeats = currentSeats - 1
93+
const occupiedSeats = (memberCountRow?.count ?? 0) + (pendingCountRow?.count ?? 0)
94+
const nextSeats = currentSeats - 1
8895

89-
if (nextSeats < occupiedSeats) {
90-
return {
91-
reduced: false,
92-
previousSeats: currentSeats,
93-
seats: currentSeats,
94-
reason: `Cannot reduce seats below current occupancy (${occupiedSeats}).`,
96+
if (nextSeats < occupiedSeats) {
97+
return {
98+
reduced: false,
99+
previousSeats: currentSeats,
100+
seats: currentSeats,
101+
reason: `Cannot reduce seats below current occupancy (${occupiedSeats}).`,
102+
}
95103
}
96-
}
97104

98-
const stripe = requireStripeClient()
99-
const stripeSubscription = await stripe.subscriptions.retrieve(
100-
orgSubscription.stripeSubscriptionId
101-
)
105+
await tx
106+
.update(subscription)
107+
.set({ seats: nextSeats })
108+
.where(eq(subscription.id, orgSubscription.id))
109+
110+
const outboxEventId = await enqueueOutboxEvent(
111+
tx,
112+
OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
113+
{
114+
subscriptionId: orgSubscription.id,
115+
reason: 'member-removed-seat-reduction',
116+
}
117+
)
102118

103-
if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
104-
return {
105-
reduced: false,
119+
logger.info('Reduced organization seats after member removal', {
120+
organizationId,
121+
actorUserId,
122+
removedUserId,
106123
previousSeats: currentSeats,
107-
seats: currentSeats,
108-
reason: 'Stripe subscription is not active',
109-
}
110-
}
124+
seats: nextSeats,
125+
outboxEventId,
126+
})
111127

112-
const subscriptionItem = stripeSubscription.items.data[0]
113-
if (!subscriptionItem) {
114-
return {
115-
reduced: false,
116-
previousSeats: currentSeats,
117-
seats: currentSeats,
118-
reason: 'No subscription item found in Stripe subscription',
119-
}
120-
}
121-
122-
const updatedSubscription = await stripe.subscriptions.update(
123-
orgSubscription.stripeSubscriptionId,
124-
{
125-
items: [
126-
{
127-
id: subscriptionItem.id,
128-
quantity: nextSeats,
129-
},
130-
],
131-
proration_behavior: 'always_invoice',
132-
},
133-
{
134-
idempotencyKey: `seats-reduce-member-removal:${orgSubscription.stripeSubscriptionId}:${nextSeats}`,
135-
}
136-
)
137-
138-
const updatedSeats = updatedSubscription.items.data[0]?.quantity ?? nextSeats
139-
await syncSeatsFromStripeQuantity(orgSubscription.id, orgSubscription.seats, updatedSeats)
140-
141-
logger.info('Reduced organization seats after member removal', {
142-
organizationId,
143-
userId,
144-
previousSeats: currentSeats,
145-
seats: updatedSeats,
128+
return { reduced: true, previousSeats: currentSeats, seats: nextSeats, outboxEventId }
146129
})
147-
148-
return { reduced: true, previousSeats: currentSeats, seats: updatedSeats }
149130
}

0 commit comments

Comments
 (0)