Skip to content

Commit 1e0e5e4

Browse files
committed
cleanup code
1 parent 420e1df commit 1e0e5e4

File tree

18 files changed

+563
-540
lines changed

18 files changed

+563
-540
lines changed

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
hasUsableSubscriptionStatus,
1414
USABLE_SUBSCRIPTION_STATUSES,
1515
} from '@/lib/billing/subscriptions/utils'
16+
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
17+
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
1618
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
1719

1820
const logger = createLogger('OrganizationSeatsAPI')
@@ -164,8 +166,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
164166
userId: session.user.id,
165167
})
166168

167-
// Update the subscription item quantity using Stripe's recommended approach
168-
// This will automatically prorate the billing
169169
const updatedSubscription = await stripe.subscriptions.update(
170170
orgSubscription.stripeSubscriptionId,
171171
{
@@ -176,19 +176,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
176176
},
177177
],
178178
proration_behavior: 'always_invoice',
179-
}
179+
},
180+
{ idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` }
180181
)
181182

182-
// Update our local database to reflect the change
183-
// Note: This will also be updated via webhook, but we update immediately for UX
184-
await db
185-
.update(subscription)
186-
.set({
187-
seats: newSeatCount,
188-
})
189-
.where(eq(subscription.id, orgSubscription.id))
183+
await syncSeatsFromStripeQuantity(
184+
orgSubscription.id,
185+
orgSubscription.seats,
186+
updatedSubscription.items.data[0]?.quantity ?? newSeatCount
187+
)
190188

191-
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
192189
const { basePrice } = getPlanPricing(orgSubscription.plan)
193190
const newMinimumLimit = newSeatCount * basePrice
194191

@@ -200,7 +197,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
200197

201198
const currentOrgLimit =
202199
orgData.length > 0 && orgData[0].orgUsageLimit
203-
? Number.parseFloat(orgData[0].orgUsageLimit)
200+
? toNumber(toDecimal(orgData[0].orgUsageLimit))
204201
: 0
205202

206203
// Update if new minimum is higher than current limit

apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,11 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
134134
// (overage bill, usage reset, Pro restore, org delete) via
135135
// `handleSubscriptionDeleted`, so no outbox needed here.
136136
const stripe = requireStripeClient()
137-
await stripe.subscriptions.cancel(existing.stripeSubscriptionId, {
138-
prorate: true,
139-
invoice_now: true,
140-
})
137+
await stripe.subscriptions.cancel(
138+
existing.stripeSubscriptionId,
139+
{ prorate: true, invoice_now: true },
140+
{ idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` }
141+
)
141142

142143
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
143144
subscriptionId,
Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { db } from '@sim/db'
2-
import * as schema from '@sim/db/schema'
31
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
52
import { hasPaidSubscription } from '@/lib/billing'
3+
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
64
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
75

86
const logger = createLogger('BillingAuthorization')
@@ -24,14 +22,10 @@ export async function authorizeSubscriptionReference(
2422
referenceId: string,
2523
action?: string
2624
): Promise<boolean> {
27-
// `isOrgScopedSubscription` returns `false` when referenceId === userId,
28-
// which is exactly the "personal subscription" case we want to allow
29-
// without further checks.
3025
if (!isOrgScopedSubscription({ referenceId }, userId)) {
3126
return true
3227
}
3328

34-
// Only block duplicate subscriptions during upgrade/checkout, not cancel/restore/list
3529
if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) {
3630
logger.warn('Blocking checkout - active subscription already exists for organization', {
3731
userId,
@@ -40,12 +34,5 @@ export async function authorizeSubscriptionReference(
4034
return false
4135
}
4236

43-
const members = await db
44-
.select()
45-
.from(schema.member)
46-
.where(and(eq(schema.member.userId, userId), eq(schema.member.organizationId, referenceId)))
47-
48-
const member = members[0]
49-
50-
return member?.role === 'owner' || member?.role === 'admin'
37+
return isOrganizationOwnerOrAdmin(userId, referenceId)
5138
}

apps/sim/lib/billing/calculations/usage-monitor.ts

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import { db } from '@sim/db'
22
import { member, userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq, inArray } from 'drizzle-orm'
4+
import { and, eq } from 'drizzle-orm'
55
import {
66
getHighestPrioritySubscription,
77
type HighestPrioritySubscription,
88
} from '@/lib/billing/core/plan'
9-
import { getUserUsageLimit } from '@/lib/billing/core/usage'
9+
import { getPooledOrgCurrentPeriodCost, getUserUsageLimit } from '@/lib/billing/core/usage'
1010
import {
1111
computeDailyRefreshConsumed,
1212
getOrgMemberRefreshBounds,
1313
} from '@/lib/billing/credits/daily-refresh'
1414
import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers'
1515
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
16+
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
1617
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
1718
import { toError } from '@/lib/core/utils/helpers'
1819

@@ -50,23 +51,10 @@ async function computePooledOrgUsage(
5051
periodEnd: Date | null
5152
}
5253
): Promise<number> {
53-
const teamMembers = await db
54-
.select({ userId: member.userId })
55-
.from(member)
56-
.where(eq(member.organizationId, organizationId))
57-
58-
if (teamMembers.length === 0) return 0
59-
60-
const memberIds = teamMembers.map((tm) => tm.userId)
61-
const memberStatsRows = await db
62-
.select({ current: userStats.currentPeriodCost, total: userStats.totalCost })
63-
.from(userStats)
64-
.where(inArray(userStats.userId, memberIds))
65-
66-
let pooled = 0
67-
for (const stats of memberStatsRows) {
68-
pooled += Number.parseFloat(stats.current?.toString() || stats.total.toString())
69-
}
54+
const { memberIds, currentPeriodCost } = await getPooledOrgCurrentPeriodCost(organizationId)
55+
if (memberIds.length === 0) return 0
56+
57+
let pooled = currentPeriodCost
7058

7159
if (isPaid(sub.plan) && sub.periodStart) {
7260
const planDollars = getPlanTierDollars(sub.plan)
@@ -99,9 +87,7 @@ export async function checkUsageStatus(
9987
if (!isBillingEnabled) {
10088
const statsRecords = await db.select().from(userStats).where(eq(userStats.userId, userId))
10189
const currentUsage =
102-
statsRecords.length > 0
103-
? Number.parseFloat(statsRecords[0].currentPeriodCost?.toString())
104-
: 0
90+
statsRecords.length > 0 ? toNumber(toDecimal(statsRecords[0].currentPeriodCost)) : 0
10591

10692
return {
10793
percentUsed: Math.min((currentUsage / 1000) * 100, 100),
@@ -150,9 +136,7 @@ export async function checkUsageStatus(
150136
}
151137
}
152138

153-
const rawUsage = Number.parseFloat(
154-
statsRecords[0].currentPeriodCost?.toString() || statsRecords[0].totalCost.toString()
155-
)
139+
const rawUsage = toNumber(toDecimal(statsRecords[0].currentPeriodCost))
156140

157141
let refresh = 0
158142
if (sub && isPaid(sub.plan) && sub.periodStart) {
@@ -296,16 +280,12 @@ export async function checkServerSideUsageLimits(
296280
blocked: userStats.billingBlocked,
297281
blockedReason: userStats.billingBlockedReason,
298282
current: userStats.currentPeriodCost,
299-
total: userStats.totalCost,
300283
})
301284
.from(userStats)
302285
.where(eq(userStats.userId, userId))
303286
.limit(1)
304287

305-
const currentUsage =
306-
stats.length > 0
307-
? Number.parseFloat(stats[0].current?.toString() || stats[0].total.toString())
308-
: 0
288+
const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0
309289

310290
if (stats.length > 0 && stats[0].blocked) {
311291
const message =

apps/sim/lib/billing/core/billing.ts

Lines changed: 80 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,13 @@ import { createLogger } from '@sim/logger'
2929
const logger = createLogger('Billing')
3030

3131
/**
32-
* Get organization subscription directly by organization ID
32+
* Get the organization's subscription row when its status is one of
33+
* `ENTITLED_SUBSCRIPTION_STATUSES` (includes `past_due`). Use this
34+
* when making billing-side decisions (overage math, limit reads,
35+
* webhooks) where `past_due` still counts as an active paid tenant.
36+
* For product-access gating use `getOrganizationSubscriptionUsable`
37+
* (from `core/subscription.ts`), which excludes `past_due`.
38+
* Returns `null` when there is no entitled sub.
3339
*/
3440
export async function getOrganizationSubscription(organizationId: string) {
3541
try {
@@ -83,6 +89,13 @@ export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Pro
8389
* query. Used by org-scoped summary and overage calculations so we don't
8490
* call `getUserUsageData` per-member — that helper now returns the entire
8591
* pool for org-scoped subs, which would N-times-count the usage.
92+
*
93+
* The `currentPeriodCost` sum here is semantically identical to
94+
* `getPooledOrgCurrentPeriodCost` (same `LEFT JOIN` + `toDecimal`
95+
* null handling); this helper bundles the copilot fields in the same
96+
* round-trip. Never fall back to lifetime `totalCost` on nulls — the
97+
* column is `NOT NULL DEFAULT '0'` and mixing scopes would break
98+
* current-period billing math.
8699
*/
87100
async function aggregateOrgMemberStats(organizationId: string): Promise<{
88101
memberIds: string[]
@@ -123,6 +136,54 @@ async function aggregateOrgMemberStats(organizationId: string): Promise<{
123136
}
124137
}
125138

139+
/**
140+
* Compute an org's overage amount from already-fetched pool/departed
141+
* inputs. Internally performs one daily-refresh DB read to subtract
142+
* refresh credits; callers are expected to have already loaded the
143+
* pooled `currentPeriodCost` and `departedMemberUsage` (threshold
144+
* billing passes lock-held values; `calculateSubscriptionOverage`
145+
* passes lockless values from `aggregateOrgMemberStats`). Both
146+
* callers route through this to keep the overage math in one place.
147+
*/
148+
export async function computeOrgOverageAmount(params: {
149+
plan: string | null
150+
seats: number | null
151+
periodStart: Date | null
152+
periodEnd: Date | null
153+
organizationId: string
154+
pooledCurrentPeriodCost: number
155+
departedMemberUsage: number
156+
memberIds: string[]
157+
}): Promise<{
158+
effectiveUsage: number
159+
baseSubscriptionAmount: number
160+
dailyRefreshDeduction: number
161+
totalOverage: number
162+
}> {
163+
const totalUsage = params.pooledCurrentPeriodCost + params.departedMemberUsage
164+
165+
let dailyRefreshDeduction = 0
166+
const planDollars = getPlanTierDollars(params.plan)
167+
if (planDollars > 0 && params.periodStart && params.memberIds.length > 0) {
168+
const userBounds = await getOrgMemberRefreshBounds(params.organizationId, params.periodStart)
169+
dailyRefreshDeduction = await computeDailyRefreshConsumed({
170+
userIds: params.memberIds,
171+
periodStart: params.periodStart,
172+
periodEnd: params.periodEnd ?? null,
173+
planDollars,
174+
seats: params.seats || 1,
175+
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
176+
})
177+
}
178+
179+
const effectiveUsage = Math.max(0, totalUsage - dailyRefreshDeduction)
180+
const { basePrice } = getPlanPricing(params.plan ?? '')
181+
const baseSubscriptionAmount = (params.seats || 1) * basePrice
182+
const totalOverage = Math.max(0, effectiveUsage - baseSubscriptionAmount)
183+
184+
return { effectiveUsage, baseSubscriptionAmount, dailyRefreshDeduction, totalOverage }
185+
}
186+
126187
/**
127188
* Calculate overage amount for a subscription
128189
* Shared logic between invoice.finalized and customer.subscription.deleted handlers
@@ -150,49 +211,38 @@ export async function calculateSubscriptionOverage(sub: {
150211

151212
if (isOrgScoped) {
152213
const pooled = await aggregateOrgMemberStats(sub.referenceId)
153-
const totalTeamUsageDecimal = toDecimal(pooled.currentPeriodCost)
154214

155215
const orgData = await db
156216
.select({ departedMemberUsage: organization.departedMemberUsage })
157217
.from(organization)
158218
.where(eq(organization.id, sub.referenceId))
159219
.limit(1)
160220

161-
const departedUsageDecimal =
162-
orgData.length > 0 ? toDecimal(orgData[0].departedMemberUsage) : new Decimal(0)
163-
164-
const totalUsageWithDepartedDecimal = totalTeamUsageDecimal.plus(departedUsageDecimal)
221+
const departedMemberUsage =
222+
orgData.length > 0 ? toNumber(toDecimal(orgData[0].departedMemberUsage)) : 0
165223

166-
let dailyRefreshDeduction = 0
167-
const planDollars = getPlanTierDollars(sub.plan)
168-
if (planDollars > 0 && sub.periodStart) {
169-
const userBounds = await getOrgMemberRefreshBounds(sub.referenceId, sub.periodStart)
170-
dailyRefreshDeduction = await computeDailyRefreshConsumed({
171-
userIds: pooled.memberIds,
172-
periodStart: sub.periodStart,
173-
periodEnd: sub.periodEnd ?? null,
174-
planDollars,
175-
seats: sub.seats || 1,
176-
userBounds: Object.keys(userBounds).length > 0 ? userBounds : undefined,
177-
})
178-
}
224+
const { totalOverage, effectiveUsage, baseSubscriptionAmount } = await computeOrgOverageAmount({
225+
plan: sub.plan,
226+
seats: sub.seats ?? null,
227+
periodStart: sub.periodStart ?? null,
228+
periodEnd: sub.periodEnd ?? null,
229+
organizationId: sub.referenceId,
230+
pooledCurrentPeriodCost: pooled.currentPeriodCost,
231+
departedMemberUsage,
232+
memberIds: pooled.memberIds,
233+
})
179234

180-
const effectiveUsageDecimal = Decimal.max(
181-
0,
182-
totalUsageWithDepartedDecimal.minus(toDecimal(dailyRefreshDeduction))
183-
)
184-
const { basePrice } = getPlanPricing(sub.plan ?? '')
185-
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
186-
totalOverageDecimal = Decimal.max(0, effectiveUsageDecimal.minus(baseSubscriptionAmount))
235+
totalOverageDecimal = toDecimal(totalOverage)
187236

188237
logger.info('Calculated org-scoped overage', {
189238
subscriptionId: sub.id,
190239
plan: sub.plan,
191-
currentMemberUsage: toNumber(totalTeamUsageDecimal),
192-
departedMemberUsage: toNumber(departedUsageDecimal),
193-
totalUsage: toNumber(totalUsageWithDepartedDecimal),
240+
currentMemberUsage: pooled.currentPeriodCost,
241+
departedMemberUsage,
242+
totalUsage: pooled.currentPeriodCost + departedMemberUsage,
243+
effectiveUsage,
194244
baseSubscriptionAmount,
195-
totalOverage: toNumber(totalOverageDecimal),
245+
totalOverage,
196246
})
197247
} else if (isPro(sub.plan)) {
198248
// Read user_stats directly (not via `getUserUsageData`). Priority

0 commit comments

Comments
 (0)