@@ -4,13 +4,10 @@ import { createLogger } from '@sim/logger'
44import { and , count , eq , gt , inArray , ne } from 'drizzle-orm'
55import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
66import { 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'
139import { isBillingEnabled } from '@/lib/core/config/feature-flags'
10+ import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
1411
1512const 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