@@ -9,6 +9,7 @@ import type { db } from '@sim/db'
99import * as schema from '@sim/db/schema'
1010import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
1111import { getPostgresErrorCode } from '@sim/utils/errors'
12+ import { sleep } from '@sim/utils/helpers'
1213import { generateId } from '@sim/utils/id'
1314import { eq , sql } from 'drizzle-orm'
1415import { drizzle } from 'drizzle-orm/postgres-js'
@@ -20,7 +21,10 @@ const databaseUrl = readTestDatabaseUrl()
2021
2122vi . mock ( '@sim/db' , ( ) => ( { db : { transaction } , dbReplica : { } } ) )
2223vi . mock ( '@/lib/billing/core/plan' , ( ) => ( { getHighestPrioritySubscription : vi . fn ( ) } ) )
23- vi . mock ( '@/lib/billing/subscriptions/utils' , ( ) => ( { isOrgScopedSubscription : vi . fn ( ) } ) )
24+ vi . mock ( '@/lib/billing/subscriptions/utils' , async ( importOriginal ) => ( {
25+ ...( await importOriginal < typeof import ( '@/lib/billing/subscriptions/utils' ) > ( ) ) ,
26+ isOrgScopedSubscription : vi . fn ( ) ,
27+ } ) )
2428
2529import {
2630 CumulativeUsageContextMismatchError ,
@@ -31,6 +35,7 @@ import {
3135 type RecordCumulativeUsageParams ,
3236 recordCumulativeUsage ,
3337} from '@/lib/billing/core/usage-log'
38+ import { claimTerminalPeriod } from '@/lib/billing/cycle-close'
3439
3540const require = createRequire ( import . meta. url )
3641const commonJsPostgres = require ( 'postgres' ) as typeof postgres
@@ -549,6 +554,70 @@ describe('Cumulative billing with PostgreSQL', () => {
549554 expect ( await ledgerRows ( ) ) . toEqual ( [ { event_key : usage ( 0 ) . eventKey , cost : '0.4' } ] )
550555 } )
551556
557+ /**
558+ * Resolves true once a session waits on a row lock of the subscription table, or false once
559+ * `work` settles without anyone waiting, so a missing lock fails instead of hanging.
560+ */
561+ async function waitsOnSubscriptionRow ( work : Promise < unknown > ) {
562+ let settled = false
563+ work . then (
564+ ( ) => {
565+ settled = true
566+ } ,
567+ ( ) => {
568+ settled = true
569+ }
570+ )
571+ while ( ! settled ) {
572+ const [ row ] = await connection < { waiting : boolean } [ ] > `
573+ select exists (
574+ select 1 from pg_locks
575+ where locktype = 'tuple' and relation = 'subscription'::regclass
576+ ) as waiting
577+ `
578+ if ( row . waiting ) return true
579+ await sleep ( 10 )
580+ }
581+ return false
582+ }
583+
584+ it ( 'makes the terminal claim wait for an in-flight charge, so the final sum includes it' , async ( ) => {
585+ await setSubscriptionPeriod ( 0 )
586+ await charge ( 0.4 )
587+ const pause = pauseNextTransaction ( )
588+ const inFlight = charge ( 0.6 )
589+ let claim : Promise < unknown > = Promise . resolve ( )
590+ try {
591+ await pause . reached . promise
592+ claim = claimTerminalPeriod ( 'sub-1' )
593+ expect ( await waitsOnSubscriptionRow ( claim ) ) . toBe ( true )
594+ } finally {
595+ pause . release . resolve ( )
596+ await inFlight
597+ await claim
598+ }
599+ expect ( await stampedTotal ( 0 ) ) . toBeCloseTo ( 0.6 , 9 )
600+ await expect ( charge ( 0.8 ) ) . rejects . toBeInstanceOf ( CumulativeUsagePeriodClosedError )
601+ } )
602+
603+ it ( 'refuses a charge that waited on an in-flight terminal claim' , async ( ) => {
604+ await setSubscriptionPeriod ( 0 )
605+ await charge ( 0.4 )
606+ const pause = pauseNextTransaction ( )
607+ const claim = claimTerminalPeriod ( 'sub-1' )
608+ let late : Promise < unknown > = Promise . resolve ( )
609+ try {
610+ await pause . reached . promise
611+ late = charge ( 0.6 )
612+ expect ( await waitsOnSubscriptionRow ( late ) ) . toBe ( true )
613+ } finally {
614+ pause . release . resolve ( )
615+ await claim
616+ }
617+ await expect ( late ) . rejects . toBeInstanceOf ( CumulativeUsagePeriodClosedError )
618+ expect ( await stampedTotal ( 0 ) ) . toBeCloseTo ( 0.4 , 9 )
619+ } )
620+
552621 it ( 'holds an early period-start move until an in-flight top-up commits' , async ( ) => {
553622 const start = new Date ( Date . now ( ) - 24 * 60 * 60 * 1000 )
554623 const end = new Date ( Date . now ( ) + 30 * 24 * 60 * 60 * 1000 )
0 commit comments