@@ -67,6 +67,9 @@ vi.mock('@sim/db/schema', () => ({
6767 billedOverageThisPeriod : 'userStats.billedOverageThisPeriod' ,
6868 creditBalance : 'userStats.creditBalance' ,
6969 currentPeriodCost : 'userStats.currentPeriodCost' ,
70+ lastPeriodCost : 'userStats.lastPeriodCost' ,
71+ proPeriodCostSnapshot : 'userStats.proPeriodCostSnapshot' ,
72+ proPeriodCostSnapshotAt : 'userStats.proPeriodCostSnapshotAt' ,
7073 userId : 'userStats.userId' ,
7174 } ,
7275} ) )
@@ -148,22 +151,55 @@ function buildSelectChain<T>(rows: T[]) {
148151 }
149152}
150153
151- function buildCustomerSelectChain ( customerId = 'cus_1' ) {
152- return buildSelectChain ( [ { stripeCustomerId : customerId } ] )
154+ function buildPersonalSelectChain ( customerId = 'cus_1' ) {
155+ return buildSelectChain ( [
156+ {
157+ currentPeriodCost : '0' ,
158+ proPeriodCostSnapshot : '0' ,
159+ proPeriodCostSnapshotAt : null ,
160+ lastPeriodCost : '0' ,
161+ stripeCustomerId : customerId ,
162+ } ,
163+ ] )
164+ }
165+
166+ function buildPersonalSnapshotSelectChain ( {
167+ currentPeriodCost = '0' ,
168+ proPeriodCostSnapshot = '0' ,
169+ proPeriodCostSnapshotAt = null ,
170+ lastPeriodCost = '0' ,
171+ } : {
172+ currentPeriodCost ?: string
173+ proPeriodCostSnapshot ?: string
174+ proPeriodCostSnapshotAt ?: Date | null
175+ lastPeriodCost ?: string
176+ } ) {
177+ return buildSelectChain ( [
178+ {
179+ currentPeriodCost,
180+ proPeriodCostSnapshot,
181+ proPeriodCostSnapshotAt,
182+ lastPeriodCost,
183+ } ,
184+ ] )
153185}
154186
155187function buildStatsSelectChain ( ) {
156188 const result = {
189+ for : vi . fn ( ( ) => result ) ,
157190 limit : mockTxStatsLimit ,
158191 then : ( resolve : ( value : unknown [ ] ) => unknown , reject ?: ( reason : unknown ) => unknown ) =>
159192 Promise . resolve ( mockTxStatsLimit ( ) ) . then ( resolve , reject ) ,
160193 }
161194
162195 return {
163196 from : vi . fn ( ( ) => ( {
164- where : vi . fn ( ( ) => ( {
165- for : vi . fn ( ( ) => result ) ,
197+ leftJoin : vi . fn ( ( ) => ( {
198+ innerJoin : vi . fn ( ( ) => ( {
199+ where : vi . fn ( ( ) => result ) ,
200+ } ) ) ,
166201 } ) ) ,
202+ where : vi . fn ( ( ) => result ) ,
167203 } ) ) ,
168204 }
169205}
@@ -186,7 +222,7 @@ describe('checkAndBillOverageThreshold', () => {
186222 mockIsFree . mockReturnValue ( false )
187223 mockIsEnterprise . mockReturnValue ( false )
188224 mockIsOrgScopedSubscription . mockReturnValue ( false )
189- mockDbSelect . mockImplementation ( ( ) => buildCustomerSelectChain ( ) )
225+ mockDbSelect . mockImplementation ( ( ) => buildPersonalSelectChain ( ) )
190226 mockTxSelect . mockImplementation ( ( ) => buildStatsSelectChain ( ) )
191227 mockTxUpdate . mockImplementation ( ( ) => buildUpdateChain ( ) )
192228 mockTxExecute . mockResolvedValue ( undefined )
@@ -209,13 +245,22 @@ describe('checkAndBillOverageThreshold', () => {
209245 periodEnd : userSubscription . periodEnd ,
210246 } )
211247 expect ( mockDbTransaction ) . not . toHaveBeenCalled ( )
212- expect ( mockDbSelect ) . not . toHaveBeenCalled ( )
248+ expect ( mockDbSelect ) . toHaveBeenCalledTimes ( 1 )
213249 expect ( mockEnqueueOutboxEvent ) . not . toHaveBeenCalled ( )
214250 } )
215251
216252 it ( 'calculates overage before opening the short user_stats transaction' , async ( ) => {
217253 mockCalculateSubscriptionOverage . mockResolvedValue ( 250 )
218- mockTxStatsLimit . mockResolvedValue ( [ { billedOverageThisPeriod : '0' , creditBalance : '0' } ] )
254+ mockTxStatsLimit . mockResolvedValue ( [
255+ {
256+ currentPeriodCost : '0' ,
257+ proPeriodCostSnapshot : '0' ,
258+ proPeriodCostSnapshotAt : null ,
259+ lastPeriodCost : '0' ,
260+ billedOverageThisPeriod : '0' ,
261+ creditBalance : '0' ,
262+ } ,
263+ ] )
219264
220265 await checkAndBillOverageThreshold ( 'user-1' )
221266
@@ -230,7 +275,16 @@ describe('checkAndBillOverageThreshold', () => {
230275
231276 it ( 'rechecks billed overage while locked before enqueueing an invoice' , async ( ) => {
232277 mockCalculateSubscriptionOverage . mockResolvedValue ( 250 )
233- mockTxStatsLimit . mockResolvedValue ( [ { billedOverageThisPeriod : '200' , creditBalance : '0' } ] )
278+ mockTxStatsLimit . mockResolvedValue ( [
279+ {
280+ currentPeriodCost : '0' ,
281+ proPeriodCostSnapshot : '0' ,
282+ proPeriodCostSnapshotAt : null ,
283+ lastPeriodCost : '0' ,
284+ billedOverageThisPeriod : '200' ,
285+ creditBalance : '0' ,
286+ } ,
287+ ] )
234288
235289 await checkAndBillOverageThreshold ( 'user-1' )
236290
@@ -240,6 +294,29 @@ describe('checkAndBillOverageThreshold', () => {
240294 expect ( mockEnqueueOutboxEvent ) . not . toHaveBeenCalled ( )
241295 } )
242296
297+ it ( 'skips personal threshold billing when locked usage inputs changed' , async ( ) => {
298+ mockCalculateSubscriptionOverage . mockResolvedValue ( 250 )
299+ mockDbSelect
300+ . mockImplementationOnce ( ( ) => buildPersonalSnapshotSelectChain ( { currentPeriodCost : '250' } ) )
301+ . mockImplementationOnce ( ( ) => buildPersonalSelectChain ( ) )
302+ mockTxStatsLimit . mockResolvedValue ( [
303+ {
304+ currentPeriodCost : '0' ,
305+ proPeriodCostSnapshot : '0' ,
306+ proPeriodCostSnapshotAt : null ,
307+ lastPeriodCost : '250' ,
308+ billedOverageThisPeriod : '0' ,
309+ creditBalance : '0' ,
310+ } ,
311+ ] )
312+
313+ await checkAndBillOverageThreshold ( 'user-1' )
314+
315+ expect ( mockDbTransaction ) . toHaveBeenCalled ( )
316+ expect ( mockTxUpdate ) . not . toHaveBeenCalled ( )
317+ expect ( mockEnqueueOutboxEvent ) . not . toHaveBeenCalled ( )
318+ } )
319+
243320 it ( 'computes organization overage before opening the locked transaction' , async ( ) => {
244321 mockIsOrgScopedSubscription . mockReturnValue ( true )
245322 mockIsOrganizationBillingBlocked . mockResolvedValue ( false )
@@ -267,10 +344,17 @@ describe('checkAndBillOverageThreshold', () => {
267344 effectiveUsage : 350 ,
268345 } )
269346 mockTxStatsLimit
347+ . mockResolvedValueOnce ( [ { userId : 'owner-1' } ] )
348+ . mockResolvedValueOnce ( [ { billedOverageThisPeriod : '0' } ] )
349+ . mockResolvedValueOnce ( [ { creditBalance : '0' , departedMemberUsage : '25' } ] )
270350 . mockResolvedValueOnce ( [
271- { userId : 'owner-1' , currentPeriodCost : '350' , billedOverageThisPeriod : '0' } ,
351+ {
352+ userId : 'owner-1' ,
353+ role : 'owner' ,
354+ currentPeriodCost : '350' ,
355+ departedMemberUsage : '25' ,
356+ } ,
272357 ] )
273- . mockResolvedValueOnce ( [ { creditBalance : '0' , departedMemberUsage : '25' } ] )
274358
275359 await checkAndBillOverageThreshold ( 'user-1' )
276360
@@ -319,10 +403,121 @@ describe('checkAndBillOverageThreshold', () => {
319403 effectiveUsage : 350 ,
320404 } )
321405 mockTxStatsLimit
406+ . mockResolvedValueOnce ( [ { userId : 'owner-1' } ] )
407+ . mockResolvedValueOnce ( [ { billedOverageThisPeriod : '0' } ] )
408+ . mockResolvedValueOnce ( [ { creditBalance : '0' , departedMemberUsage : '75' } ] )
322409 . mockResolvedValueOnce ( [
323- { userId : 'owner-1' , currentPeriodCost : '350' , billedOverageThisPeriod : '0' } ,
410+ {
411+ userId : 'owner-1' ,
412+ role : 'owner' ,
413+ currentPeriodCost : '350' ,
414+ departedMemberUsage : '75' ,
415+ } ,
416+ ] )
417+
418+ await checkAndBillOverageThreshold ( 'user-1' )
419+
420+ expect ( mockDbTransaction ) . toHaveBeenCalled ( )
421+ expect ( mockEnqueueOutboxEvent ) . not . toHaveBeenCalled ( )
422+ expect ( mockTxUpdate ) . not . toHaveBeenCalled ( )
423+ } )
424+
425+ it ( 'rechecks organization billed overage on the locked owner tracker' , async ( ) => {
426+ mockIsOrgScopedSubscription . mockReturnValue ( true )
427+ mockIsOrganizationBillingBlocked . mockResolvedValue ( false )
428+ mockGetOrganizationSubscriptionUsable . mockResolvedValue ( {
429+ plan : 'team' ,
430+ seats : 2 ,
431+ periodStart : new Date ( '2026-05-01T00:00:00.000Z' ) ,
432+ periodEnd : new Date ( '2026-06-01T00:00:00.000Z' ) ,
433+ stripeSubscriptionId : 'sub_team_1' ,
434+ stripeCustomerId : 'cus_team_1' ,
435+ } )
436+ mockDbSelect . mockImplementationOnce ( ( ) =>
437+ buildSelectChain ( [
438+ {
439+ userId : 'owner-1' ,
440+ role : 'owner' ,
441+ currentPeriodCost : '350' ,
442+ departedMemberUsage : '25' ,
443+ } ,
444+ ] )
445+ )
446+ mockComputeOrgOverageAmount . mockResolvedValue ( {
447+ totalOverage : 250 ,
448+ baseSubscriptionAmount : 100 ,
449+ effectiveUsage : 350 ,
450+ } )
451+ mockTxStatsLimit
452+ . mockResolvedValueOnce ( [ { userId : 'owner-1' } ] )
453+ . mockResolvedValueOnce ( [ { billedOverageThisPeriod : '200' } ] )
454+ . mockResolvedValueOnce ( [ { creditBalance : '0' , departedMemberUsage : '25' } ] )
455+ . mockResolvedValueOnce ( [
456+ {
457+ userId : 'owner-1' ,
458+ role : 'owner' ,
459+ currentPeriodCost : '350' ,
460+ departedMemberUsage : '25' ,
461+ } ,
462+ ] )
463+
464+ await checkAndBillOverageThreshold ( 'user-1' )
465+
466+ expect ( mockDbTransaction ) . toHaveBeenCalled ( )
467+ expect ( mockEnqueueOutboxEvent ) . not . toHaveBeenCalled ( )
468+ expect ( mockTxUpdate ) . not . toHaveBeenCalled ( )
469+ } )
470+
471+ it ( 'skips stale organization overage when owner identity changed' , async ( ) => {
472+ mockIsOrgScopedSubscription . mockReturnValue ( true )
473+ mockIsOrganizationBillingBlocked . mockResolvedValue ( false )
474+ mockGetOrganizationSubscriptionUsable . mockResolvedValue ( {
475+ plan : 'team' ,
476+ seats : 2 ,
477+ periodStart : new Date ( '2026-05-01T00:00:00.000Z' ) ,
478+ periodEnd : new Date ( '2026-06-01T00:00:00.000Z' ) ,
479+ stripeSubscriptionId : 'sub_team_1' ,
480+ stripeCustomerId : 'cus_team_1' ,
481+ } )
482+ mockDbSelect . mockImplementationOnce ( ( ) =>
483+ buildSelectChain ( [
484+ {
485+ userId : 'owner-1' ,
486+ role : 'owner' ,
487+ currentPeriodCost : '350' ,
488+ departedMemberUsage : '25' ,
489+ } ,
490+ {
491+ userId : 'member-1' ,
492+ role : 'member' ,
493+ currentPeriodCost : '25' ,
494+ departedMemberUsage : '25' ,
495+ } ,
496+ ] )
497+ )
498+ mockComputeOrgOverageAmount . mockResolvedValue ( {
499+ totalOverage : 250 ,
500+ baseSubscriptionAmount : 100 ,
501+ effectiveUsage : 350 ,
502+ } )
503+ mockTxStatsLimit
504+ . mockResolvedValueOnce ( [ { userId : 'member-1' } ] )
505+ . mockResolvedValueOnce ( [ { billedOverageThisPeriod : '0' } ] )
506+ . mockResolvedValueOnce ( [ { creditBalance : '0' , departedMemberUsage : '25' } ] )
507+ . mockResolvedValueOnce ( [
508+ {
509+ userId : 'owner-1' ,
510+ role : 'member' ,
511+ currentPeriodCost : '350' ,
512+ departedMemberUsage : '25' ,
513+ } ,
514+ {
515+ userId : 'member-1' ,
516+ role : 'owner' ,
517+ currentPeriodCost : '25' ,
518+ departedMemberUsage : '25' ,
519+ } ,
324520 ] )
325- . mockResolvedValueOnce ( [ { creditBalance : '0' , departedMemberUsage : '75' } ] )
326521
327522 await checkAndBillOverageThreshold ( 'user-1' )
328523
0 commit comments