@@ -29,7 +29,13 @@ import { createLogger } from '@sim/logger'
2929const 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 */
3440export 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 */
87100async 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