@@ -43,6 +43,7 @@ vi.mock('@sim/db', () => ({
4343vi . mock ( '@/lib/billing/stripe-client' , ( ) => stripeClientMock )
4444vi . mock ( '@/lib/core/config/env-flags' , ( ) => envFlagsMock )
4545
46+ import { requestDashboardSubscriptionCancellation } from '@/lib/admin/subscription-lifecycle'
4647import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter'
4748import {
4849 pauseProSubscriptionForOrgCoverage ,
@@ -132,6 +133,7 @@ beforeAll(async () => {
132133 'organization' ,
133134 'workspace' ,
134135 'permissions' ,
136+ 'audit_log' ,
135137 ] ) {
136138 await connection . unsafe ( `CREATE TABLE "${ table } " (LIKE public."${ table } " INCLUDING ALL)` )
137139 }
@@ -283,6 +285,18 @@ async function storedSubscription(subscriptionId: string) {
283285 return row
284286}
285287
288+ /** Resolves once another backend is blocked on a lock, i.e. the racing transaction is parked. */
289+ async function untilAnotherTransactionWaitsOnALock ( ) {
290+ for ( let attempt = 0 ; attempt < 200 ; attempt ++ ) {
291+ const [ row ] = await connection < { waiting : number } [ ] > `
292+ select count(*)::int as waiting from pg_stat_activity
293+ where datname = current_database() and wait_event_type = 'Lock'`
294+ if ( row . waiting > 0 ) return
295+ await new Promise < void > ( ( resolve ) => setImmediate ( resolve ) )
296+ }
297+ throw new Error ( 'The racing transaction never waited on a lock' )
298+ }
299+
286300describe ( 'cancel_at_period_end sync' , ( ) => {
287301 it ( 'pushes the latest value when an earlier sync lands in Stripe after a newer one' , async ( ) => {
288302 const pro = await createProUserInPaidOrganization ( )
@@ -618,18 +632,6 @@ describe('cancel_at_period_end sync', () => {
618632} )
619633
620634describe ( 'Team activation' , ( ) => {
621- /** Resolves once another backend is blocked on a lock, i.e. the racing transaction is parked. */
622- async function untilAnotherTransactionWaitsOnALock ( ) {
623- for ( let attempt = 0 ; attempt < 200 ; attempt ++ ) {
624- const [ row ] = await connection < { waiting : number } [ ] > `
625- select count(*)::int as waiting from pg_stat_activity
626- where datname = current_database() and wait_event_type = 'Lock'`
627- if ( row . waiting > 0 ) return
628- await new Promise < void > ( ( resolve ) => setImmediate ( resolve ) )
629- }
630- throw new Error ( 'The racing transaction never waited on a lock' )
631- }
632-
633635 it ( 'records the cleared cancellation when a cancel is committed while it activates Team' , async ( ) => {
634636 const owner = await createUser ( 'owner' )
635637 const subscriptionId = generateId ( )
@@ -688,6 +690,89 @@ describe('Team activation', () => {
688690 } )
689691} )
690692
693+ describe ( 'operator retry' , ( ) => {
694+ it ( 'requeues a dead-lettered sync while a writer commits a new value for the subscription' , async ( ) => {
695+ const pro = await createProUserInPaidOrganization ( )
696+ await pauseProSubscriptionForOrgCoverage ( pro . userId )
697+ const pauseSync = await latestOutboxEventId (
698+ OUTBOX_EVENT_TYPES . STRIPE_SYNC_CANCEL_AT_PERIOD_END ,
699+ pro . subscriptionId
700+ )
701+ await deadLetter ( pauseSync )
702+
703+ let releaseWriter : ( ) => void = ( ) => { }
704+ const writerHeld = new Promise < void > ( ( resolve ) => {
705+ releaseWriter = resolve
706+ } )
707+ const writing = testDatabase . transaction ( async ( tx ) => {
708+ await tx
709+ . update ( subscription )
710+ . set ( { cancelAtPeriodEnd : false } )
711+ . where ( eq ( subscription . id , pro . subscriptionId ) )
712+ await writerHeld
713+ await enqueueCancelAtPeriodEndSync ( tx , {
714+ stripeSubscriptionId : pro . stripeSubscriptionId ,
715+ subscriptionId : pro . subscriptionId ,
716+ cancelAtPeriodEnd : false ,
717+ reason : 'member-left-paid-org' ,
718+ } )
719+ } )
720+ const requeuing = requeueFromAdminApi ( pauseSync )
721+ await untilAnotherTransactionWaitsOnALock ( )
722+ releaseWriter ( )
723+
724+ await expect ( Promise . all ( [ writing , requeuing ] ) ) . resolves . toBeDefined ( )
725+ await deliverUnrelatedUpdate ( pro . stripeSubscriptionId )
726+ expect ( ( await storedSubscription ( pro . subscriptionId ) ) . cancelAtPeriodEnd ) . toBe ( false )
727+ } )
728+
729+ it ( 'retries a dead-lettered dashboard cancellation while a writer commits a new value' , async ( ) => {
730+ const org = await createOrganizationWithPlan ( 'team' )
731+ const operationId = generateId ( )
732+ const actor = { id : null , name : 'Admin' , email : null }
733+ await requestDashboardSubscriptionCancellation ( {
734+ organizationId : org . organizationId ,
735+ operationId,
736+ timing : 'period_end' ,
737+ actor,
738+ } )
739+ const cancelSync = await latestOutboxEventId (
740+ OUTBOX_EVENT_TYPES . STRIPE_SYNC_CANCEL_AT_PERIOD_END ,
741+ org . subscriptionId
742+ )
743+ await deadLetter ( cancelSync )
744+
745+ let releaseWriter : ( ) => void = ( ) => { }
746+ const writerHeld = new Promise < void > ( ( resolve ) => {
747+ releaseWriter = resolve
748+ } )
749+ const writing = testDatabase . transaction ( async ( tx ) => {
750+ await tx
751+ . update ( subscription )
752+ . set ( { cancelAtPeriodEnd : false } )
753+ . where ( eq ( subscription . id , org . subscriptionId ) )
754+ await writerHeld
755+ await enqueueCancelAtPeriodEndSync ( tx , {
756+ stripeSubscriptionId : org . stripeSubscriptionId ,
757+ subscriptionId : org . subscriptionId ,
758+ cancelAtPeriodEnd : false ,
759+ reason : 'pro-to-team-conversion' ,
760+ } )
761+ } )
762+ const retrying = requestDashboardSubscriptionCancellation ( {
763+ organizationId : org . organizationId ,
764+ operationId,
765+ timing : 'period_end' ,
766+ actor,
767+ } )
768+ await untilAnotherTransactionWaitsOnALock ( )
769+ releaseWriter ( )
770+
771+ await expect ( Promise . all ( [ writing , retrying ] ) ) . resolves . toBeDefined ( )
772+ expect ( ( await storedSubscription ( org . subscriptionId ) ) . cancelAtPeriodEnd ) . toBe ( true )
773+ } )
774+ } )
775+
691776describe ( 'customer contact sync' , ( ) => {
692777 it ( 'pushes the current owner when an earlier sync lands in Stripe after a newer one' , async ( ) => {
693778 const [ first , second , third ] = await Promise . all ( [
0 commit comments