@@ -19,6 +19,7 @@ import {
1919} from '@/lib/billing/core/usage-log'
2020import { computeWeeklyRefreshConsumed } from '@/lib/billing/credits/weekly-refresh'
2121import { getPlanWeeklyRefreshDollars , isEnterprise , isFree } from '@/lib/billing/plan-helpers'
22+ import { hasOutstandingSandboxUsageForPeriod } from '@/lib/billing/sandbox-period-close'
2223import { ENTITLED_SUBSCRIPTION_STATUSES , getPlanPricing } from '@/lib/billing/subscriptions/utils'
2324import { toDecimal , toNumber } from '@/lib/billing/utils/decimal'
2425import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
@@ -282,6 +283,29 @@ async function claimCloseMarker(
282283 return claimed . length > 0
283284}
284285
286+ async function guardCycleCloseAgainstSandboxWriters (
287+ tx : DbOrTx ,
288+ subscriptionId : string ,
289+ periodStart : Date ,
290+ billingEntity : { type : 'user' | 'organization' ; id : string }
291+ ) : Promise < 'ready' | 'already-closed' | 'writers-pending' > {
292+ const [ current ] = await tx
293+ . select ( { lastClosedPeriodStart : subscriptionTable . lastClosedPeriodStart } )
294+ . from ( subscriptionTable )
295+ . where ( eq ( subscriptionTable . id , subscriptionId ) )
296+ . for ( 'update' )
297+ . limit ( 1 )
298+ if (
299+ current ?. lastClosedPeriodStart &&
300+ current . lastClosedPeriodStart . getTime ( ) >= periodStart . getTime ( )
301+ ) {
302+ return 'already-closed'
303+ }
304+ return ( await hasOutstandingSandboxUsageForPeriod ( tx , billingEntity , periodStart ) )
305+ ? 'writers-pending'
306+ : 'ready'
307+ }
308+
285309/**
286310 * Close the most recently elapsed billing period for one subscription.
287311 *
@@ -385,8 +409,22 @@ export async function closeElapsedBillingPeriod(
385409 }
386410 if ( closeFrom . getTime ( ) >= periodStart . getTime ( ) ) {
387411 // Degenerate window (clock skew or a shortened period) — just advance.
388- const advanced = await db . transaction ( async ( tx ) => claimCloseMarker ( tx , sub . id , periodStart ) )
389- return { ...base , status : advanced ? 'closed' : 'already-closed' }
412+ const claim = await db . transaction ( async ( tx ) => {
413+ await tx . execute ( sql . raw ( `SET LOCAL lock_timeout = '${ BILLING_LOCK_TIMEOUT_MS } ms'` ) )
414+ const guard = await guardCycleCloseAgainstSandboxWriters (
415+ tx ,
416+ sub . id ,
417+ periodStart ,
418+ billingEntity
419+ )
420+ if ( guard !== 'ready' ) return guard
421+ return ( await claimCloseMarker ( tx , sub . id , periodStart ) ) ? 'closed' : 'already-closed'
422+ } )
423+ return {
424+ ...base ,
425+ status :
426+ claim === 'closed' ? 'closed' : claim === 'already-closed' ? 'already-closed' : 'skipped' ,
427+ }
390428 }
391429
392430 const closedRange = { from : closeFrom , to : periodStart }
@@ -396,8 +434,22 @@ export async function closeElapsedBillingPeriod(
396434 // Reporting-anchor orgs derive every usage window live from the anchor;
397435 // there is nothing to bill or book here. Advance the marker so the sweep
398436 // stays quiet.
399- const advanced = await db . transaction ( async ( tx ) => claimCloseMarker ( tx , sub . id , periodStart ) )
400- return { ...base , status : advanced ? 'closed' : 'already-closed' }
437+ const claim = await db . transaction ( async ( tx ) => {
438+ await tx . execute ( sql . raw ( `SET LOCAL lock_timeout = '${ BILLING_LOCK_TIMEOUT_MS } ms'` ) )
439+ const guard = await guardCycleCloseAgainstSandboxWriters (
440+ tx ,
441+ sub . id ,
442+ periodStart ,
443+ billingEntity
444+ )
445+ if ( guard !== 'ready' ) return guard
446+ return ( await claimCloseMarker ( tx , sub . id , periodStart ) ) ? 'closed' : 'already-closed'
447+ } )
448+ return {
449+ ...base ,
450+ status :
451+ claim === 'closed' ? 'closed' : claim === 'already-closed' ? 'already-closed' : 'skipped' ,
452+ }
401453 }
402454
403455 const [ usageByUser , copilotByUser ] = await Promise . all ( [
@@ -485,7 +537,7 @@ export async function closeElapsedBillingPeriod(
485537 async (
486538 tx
487539 ) : Promise < {
488- status : 'closed' | 'already-closed' | 'membership-changed'
540+ status : 'closed' | 'already-closed' | 'membership-changed' | 'writers-pending'
489541 billed : number
490542 creditsApplied : number
491543 } > => {
@@ -519,17 +571,18 @@ export async function closeElapsedBillingPeriod(
519571 // Re-check the marker under the locks: a concurrent closer that already
520572 // committed makes this a no-op (its billedOverage reset must not be
521573 // mistaken for unbilled overage).
522- const [ current ] = await tx
523- . select ( { lastClosedPeriodStart : subscriptionTable . lastClosedPeriodStart } )
524- . from ( subscriptionTable )
525- . where ( eq ( subscriptionTable . id , sub . id ) )
526- . limit ( 1 )
527- if (
528- current ?. lastClosedPeriodStart &&
529- current . lastClosedPeriodStart . getTime ( ) >= periodStart . getTime ( )
530- ) {
574+ const sandboxWriterGuard = await guardCycleCloseAgainstSandboxWriters (
575+ tx ,
576+ sub . id ,
577+ periodStart ,
578+ billingEntity
579+ )
580+ if ( sandboxWriterGuard === 'already-closed' ) {
531581 return { status : 'already-closed' , billed : 0 , creditsApplied : 0 }
532582 }
583+ if ( sandboxWriterGuard === 'writers-pending' ) {
584+ return { status : 'writers-pending' , billed : 0 , creditsApplied : 0 }
585+ }
533586
534587 // Re-read the roster under the locks, mirroring threshold billing: an
535588 // owner transfer moves `billedOverageThisPeriod` between rows, so a
@@ -667,6 +720,14 @@ export async function closeElapsedBillingPeriod(
667720 } )
668721 return base
669722 }
723+ if ( closeResult . status === 'writers-pending' ) {
724+ logger . info ( 'Deferring cycle close for outstanding sandbox usage' , {
725+ subscriptionId : sub . id ,
726+ billingEntity,
727+ periodEnd : periodStart . toISOString ( ) ,
728+ } )
729+ return base
730+ }
670731
671732 logger . info ( 'Closed billing period' , {
672733 subscriptionId : sub . id ,
0 commit comments