11import { db } from '@sim/db'
2- import { member , organization , subscription , userStats } from '@sim/db/schema'
2+ import { member , userStats } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { and , eq , inArray } from 'drizzle-orm'
55import {
66 getHighestPrioritySubscription ,
77 type HighestPrioritySubscription ,
88} from '@/lib/billing/core/plan'
9- import { getOrgUsageLimit , getUserUsageLimit } from '@/lib/billing/core/usage'
9+ import { getUserUsageLimit } from '@/lib/billing/core/usage'
1010import {
1111 computeDailyRefreshConsumed ,
1212 getOrgMemberRefreshBounds ,
1313} from '@/lib/billing/credits/daily-refresh'
1414import { getPlanTierDollars , isPaid } from '@/lib/billing/plan-helpers'
15- import {
16- ENTITLED_SUBSCRIPTION_STATUSES ,
17- isOrgScopedSubscription ,
18- } from '@/lib/billing/subscriptions/utils'
15+ import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
1916import { isBillingEnabled } from '@/lib/core/config/feature-flags'
2017import { toError } from '@/lib/core/utils/helpers'
2118
@@ -39,6 +36,57 @@ interface UsageData {
3936 organizationId : string | null
4037}
4138
39+ /**
40+ * Sum `currentPeriodCost` across all members of an org, then subtract
41+ * daily-refresh credits (with per-user window bounds for mid-cycle
42+ * joiners).
43+ */
44+ async function computePooledOrgUsage (
45+ organizationId : string ,
46+ sub : {
47+ plan : string | null
48+ seats : number | null
49+ periodStart : Date | null
50+ periodEnd : Date | null
51+ }
52+ ) : 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+ }
70+
71+ if ( isPaid ( sub . plan ) && sub . periodStart ) {
72+ const planDollars = getPlanTierDollars ( sub . plan )
73+ if ( planDollars > 0 ) {
74+ const userBounds = await getOrgMemberRefreshBounds ( organizationId , sub . periodStart )
75+ const refresh = await computeDailyRefreshConsumed ( {
76+ userIds : memberIds ,
77+ periodStart : sub . periodStart ,
78+ periodEnd : sub . periodEnd ?? null ,
79+ planDollars,
80+ seats : sub . seats || 1 ,
81+ userBounds : Object . keys ( userBounds ) . length > 0 ? userBounds : undefined ,
82+ } )
83+ pooled = Math . max ( 0 , pooled - refresh )
84+ }
85+ }
86+
87+ return pooled
88+ }
89+
4290/**
4391 * Checks a user's cost usage against their subscription plan limit
4492 * and returns usage information including whether they're approaching the limit
@@ -75,47 +123,13 @@ export async function checkUsageStatus(
75123 logger . info ( 'Using stored usage limit' , { userId, limit } )
76124
77125 const subIsOrgScoped = isOrgScopedSubscription ( sub , userId )
126+ const scope : 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user'
127+ const organizationId : string | null = subIsOrgScoped && sub ? sub . referenceId : null
78128
79129 let currentUsage = 0
80- let effectiveLimit = limit
81- let scope : 'user' | 'organization' = subIsOrgScoped ? 'organization' : 'user'
82- let blockingOrgId : string | null = subIsOrgScoped && sub ? sub . referenceId : null
83130
84131 if ( subIsOrgScoped && sub ) {
85- const teamMembers = await db
86- . select ( { userId : member . userId } )
87- . from ( member )
88- . where ( eq ( member . organizationId , sub . referenceId ) )
89-
90- let pooled = 0
91- if ( teamMembers . length > 0 ) {
92- const memberIds = teamMembers . map ( ( tm ) => tm . userId )
93- const memberStatsRows = await db
94- . select ( { current : userStats . currentPeriodCost , total : userStats . totalCost } )
95- . from ( userStats )
96- . where ( inArray ( userStats . userId , memberIds ) )
97-
98- for ( const stats of memberStatsRows ) {
99- pooled += Number . parseFloat ( stats . current ?. toString ( ) || stats . total . toString ( ) )
100- }
101-
102- if ( isPaid ( sub . plan ) && sub . periodStart ) {
103- const planDollars = getPlanTierDollars ( sub . plan )
104- if ( planDollars > 0 ) {
105- const userBounds = await getOrgMemberRefreshBounds ( sub . referenceId , sub . periodStart )
106- const refresh = await computeDailyRefreshConsumed ( {
107- userIds : memberIds ,
108- periodStart : sub . periodStart ,
109- periodEnd : sub . periodEnd ?? null ,
110- planDollars,
111- seats : sub . seats || 1 ,
112- userBounds : Object . keys ( userBounds ) . length > 0 ? userBounds : undefined ,
113- } )
114- pooled = Math . max ( 0 , pooled - refresh )
115- }
116- }
117- }
118- currentUsage = pooled
132+ currentUsage = await computePooledOrgUsage ( sub . referenceId , sub )
119133 } else {
120134 const statsRecords = await db
121135 . select ( )
@@ -155,124 +169,29 @@ export async function checkUsageStatus(
155169 currentUsage = Math . max ( 0 , rawUsage - refresh )
156170 }
157171
158- // Defense-in-depth: enforce every entitled org cap the user belongs
159- // to, even when their priority sub is personal. If a secondary org
160- // pool is blocking, surface its numbers so the error message does
161- // not quote personal usage while enforcing an org cap.
162- try {
163- const memberships = await db
164- . select ( { organizationId : member . organizationId } )
165- . from ( member )
166- . where ( eq ( member . userId , userId ) )
167-
168- for ( const m of memberships ) {
169- // Already handled above as the primary org.
170- if ( subIsOrgScoped && sub && sub . referenceId === m . organizationId ) continue
171-
172- // Refresh math below needs THIS org's plan/period/seats, not
173- // the caller's primary sub (which may be a personal Pro).
174- const [ orgSub ] = await db
175- . select ( {
176- plan : subscription . plan ,
177- seats : subscription . seats ,
178- periodStart : subscription . periodStart ,
179- periodEnd : subscription . periodEnd ,
180- } )
181- . from ( subscription )
182- . where (
183- and (
184- eq ( subscription . referenceId , m . organizationId ) ,
185- inArray ( subscription . status , ENTITLED_SUBSCRIPTION_STATUSES )
186- )
187- )
188- . limit ( 1 )
189- if ( ! orgSub ) continue
190-
191- const [ org ] = await db
192- . select ( { id : organization . id } )
193- . from ( organization )
194- . where ( eq ( organization . id , m . organizationId ) )
195- . limit ( 1 )
196- if ( ! org ) continue
197-
198- // Use the same resolver as primary-path enforcement so the
199- // `basePrice × seats` floor is applied here too. Reading
200- // `organization.orgUsageLimit` raw would miss the floor when
201- // the column is null or has drifted below minimum.
202- const { limit : orgCap } = await getOrgUsageLimit ( org . id , orgSub . plan , orgSub . seats )
203-
204- const teamMembers = await db
205- . select ( { userId : member . userId } )
206- . from ( member )
207- . where ( eq ( member . organizationId , org . id ) )
208-
209- let pooledUsage = 0
210- if ( teamMembers . length > 0 ) {
211- const memberIds = teamMembers . map ( ( tm ) => tm . userId )
212- const allMemberStats = await db
213- . select ( { current : userStats . currentPeriodCost , total : userStats . totalCost } )
214- . from ( userStats )
215- . where ( inArray ( userStats . userId , memberIds ) )
216-
217- for ( const stats of allMemberStats ) {
218- pooledUsage += Number . parseFloat ( stats . current ?. toString ( ) || stats . total . toString ( ) )
219- }
220- }
221-
222- if ( isPaid ( orgSub . plan ) && orgSub . periodStart ) {
223- const planDollars = getPlanTierDollars ( orgSub . plan )
224- if ( planDollars > 0 ) {
225- const memberIds = teamMembers . map ( ( tm ) => tm . userId )
226- const userBounds = await getOrgMemberRefreshBounds ( org . id , orgSub . periodStart )
227- const orgRefreshDeduction = await computeDailyRefreshConsumed ( {
228- userIds : memberIds ,
229- periodStart : orgSub . periodStart ,
230- periodEnd : orgSub . periodEnd ?? null ,
231- planDollars,
232- seats : orgSub . seats || 1 ,
233- userBounds : Object . keys ( userBounds ) . length > 0 ? userBounds : undefined ,
234- } )
235- pooledUsage = Math . max ( 0 , pooledUsage - orgRefreshDeduction )
236- }
237- }
238-
239- if ( orgCap > 0 && pooledUsage >= orgCap ) {
240- currentUsage = pooledUsage
241- effectiveLimit = orgCap
242- scope = 'organization'
243- blockingOrgId = org . id
244- break
245- }
246- }
247- } catch ( error ) {
248- logger . warn ( 'Error checking organization usage limits' , { error, userId } )
249- }
250-
251- const percentUsed =
252- effectiveLimit > 0 ? Math . min ( ( currentUsage / effectiveLimit ) * 100 , 100 ) : 100
253-
254- const isExceeded = currentUsage >= effectiveLimit
172+ const percentUsed = limit > 0 ? Math . min ( ( currentUsage / limit ) * 100 , 100 ) : 100
173+ const isExceeded = currentUsage >= limit
255174 const isWarning = ! isExceeded && percentUsed >= WARNING_THRESHOLD
256175
257176 logger . info ( 'Final usage statistics' , {
258177 userId,
259178 currentUsage,
260- limit : effectiveLimit ,
179+ limit,
261180 percentUsed,
262181 isWarning,
263182 isExceeded,
264183 scope,
265- organizationId : blockingOrgId ,
184+ organizationId,
266185 } )
267186
268187 return {
269188 percentUsed,
270189 isWarning,
271190 isExceeded,
272191 currentUsage,
273- limit : effectiveLimit ,
192+ limit,
274193 scope,
275- organizationId : blockingOrgId ,
194+ organizationId,
276195 }
277196 } catch ( error ) {
278197 logger . error ( 'Error checking usage status' , {
0 commit comments