|
| 1 | +import { db } from '@sim/db' |
| 2 | +import { invitation, member, subscription } from '@sim/db/schema' |
| 3 | +import { createLogger } from '@sim/logger' |
| 4 | +import { and, count, eq, gt, inArray, ne } from 'drizzle-orm' |
| 5 | +import { isOrganizationBillingBlocked } from '@/lib/billing/core/access' |
| 6 | +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' |
| 13 | +import { isBillingEnabled } from '@/lib/core/config/feature-flags' |
| 14 | + |
| 15 | +const logger = createLogger('OrganizationSeats') |
| 16 | + |
| 17 | +export interface ReduceOrganizationSeatsResult { |
| 18 | + reduced: boolean |
| 19 | + previousSeats?: number |
| 20 | + seats?: number |
| 21 | + reason?: string |
| 22 | +} |
| 23 | + |
| 24 | +export async function reduceOrganizationSeatsByOne( |
| 25 | + organizationId: string, |
| 26 | + userId: string |
| 27 | +): Promise<ReduceOrganizationSeatsResult> { |
| 28 | + if (!isBillingEnabled) { |
| 29 | + return { reduced: false, reason: 'Billing is not enabled' } |
| 30 | + } |
| 31 | + |
| 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) |
| 39 | + ) |
| 40 | + ) |
| 41 | + .limit(1) |
| 42 | + |
| 43 | + if (!orgSubscription) { |
| 44 | + return { reduced: false, reason: 'No active subscription found' } |
| 45 | + } |
| 46 | + |
| 47 | + if (await isOrganizationBillingBlocked(organizationId)) { |
| 48 | + return { reduced: false, reason: 'An active subscription is required' } |
| 49 | + } |
| 50 | + |
| 51 | + if (!isTeam(orgSubscription.plan)) { |
| 52 | + return { reduced: false, reason: 'Seat changes are only available for Team plans' } |
| 53 | + } |
| 54 | + |
| 55 | + if (!orgSubscription.stripeSubscriptionId) { |
| 56 | + return { reduced: false, reason: 'No Stripe subscription found for this organization' } |
| 57 | + } |
| 58 | + |
| 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 | + } |
| 67 | + } |
| 68 | + |
| 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()) |
| 83 | + ) |
| 84 | + ) |
| 85 | + |
| 86 | + const occupiedSeats = (memberCountRow?.count ?? 0) + (pendingCountRow?.count ?? 0) |
| 87 | + const nextSeats = currentSeats - 1 |
| 88 | + |
| 89 | + if (nextSeats < occupiedSeats) { |
| 90 | + return { |
| 91 | + reduced: false, |
| 92 | + previousSeats: currentSeats, |
| 93 | + seats: currentSeats, |
| 94 | + reason: `Cannot reduce seats below current occupancy (${occupiedSeats}).`, |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + const stripe = requireStripeClient() |
| 99 | + const stripeSubscription = await stripe.subscriptions.retrieve( |
| 100 | + orgSubscription.stripeSubscriptionId |
| 101 | + ) |
| 102 | + |
| 103 | + if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { |
| 104 | + return { |
| 105 | + reduced: false, |
| 106 | + previousSeats: currentSeats, |
| 107 | + seats: currentSeats, |
| 108 | + reason: 'Stripe subscription is not active', |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 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, |
| 146 | + }) |
| 147 | + |
| 148 | + return { reduced: true, previousSeats: currentSeats, seats: updatedSeats } |
| 149 | +} |
0 commit comments