@@ -14,6 +14,7 @@ import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
1414import {
1515 type CancelAtPeriodEndSyncPayload ,
1616 cancelAtPeriodEndSyncIdempotencyKey ,
17+ readRecordedSyncValue ,
1718 type SubscriptionSeatsSyncPayload ,
1819} from '@/lib/billing/webhooks/subscription-sync'
1920import type { OutboxHandler } from '@/lib/core/outbox/service'
@@ -100,20 +101,32 @@ async function getSubscriptionSeatSyncState(subscriptionId: string) {
100101 return row ?? null
101102}
102103
103- async function readCancelAtPeriodEnd ( subscriptionId : string ) : Promise < boolean | null > {
104+ /**
105+ * The value this sync should push: the one recorded on its own event, re-read now, which every
106+ * commit keeps current on each sync that can still run. The subscription row is not used for
107+ * this, because the Stripe plugin can overwrite it with a stale webhook payload before the
108+ * reconcile step restores it. An event enqueued before values were recorded falls back to the
109+ * row. Null when the subscription no longer exists.
110+ */
111+ async function readDesiredCancelAtPeriodEnd (
112+ eventId : string ,
113+ subscriptionId : string
114+ ) : Promise < boolean | null > {
104115 const [ row ] = await db
105116 . select ( { cancelAtPeriodEnd : subscriptionTable . cancelAtPeriodEnd } )
106117 . from ( subscriptionTable )
107118 . where ( eq ( subscriptionTable . id , subscriptionId ) )
108119 . limit ( 1 )
109- return row ? Boolean ( row . cancelAtPeriodEnd ) : null
120+ if ( ! row ) return null
121+ return ( await readRecordedSyncValue ( eventId ) ) ?. cancelAtPeriodEnd ?? Boolean ( row . cancelAtPeriodEnd )
110122}
111123
112124/**
113- * Pushes the row's current value, never the payload's: racing events for one subscription each
114- * converge on the last committed value. Stripe is read first and written only when it differs,
115- * and the row is re-read after the write so a value committed while this event's request was in
116- * flight is pushed too, even when an earlier event's request lands in Stripe after a newer one.
125+ * Pushes the latest committed value (see `readDesiredCancelAtPeriodEnd`), never the claim-time
126+ * payload: racing events for one subscription each converge on it. Stripe is read first and
127+ * written only when it differs, and the value is re-read after the write so one committed while
128+ * this event's request was in flight is pushed too, even when an earlier event's request lands
129+ * in Stripe after a newer one.
117130 */
118131const stripeSyncCancelAtPeriodEnd : OutboxHandler < CancelAtPeriodEndSyncPayload > = async (
119132 payload ,
@@ -123,7 +136,7 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler<CancelAtPeriodEndSyncPayload> =
123136 const stripe = requireStripeClient ( )
124137
125138 for ( let attempt = 1 ; attempt <= MAX_SYNC_ATTEMPTS ; attempt ++ ) {
126- const desiredValue = await readCancelAtPeriodEnd ( payload . subscriptionId )
139+ const desiredValue = await readDesiredCancelAtPeriodEnd ( ctx . eventId , payload . subscriptionId )
127140 if ( desiredValue === null ) {
128141 logger . warn ( 'Subscription not found when syncing cancel_at_period_end' , {
129142 eventId : ctx . eventId ,
@@ -142,7 +155,7 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler<CancelAtPeriodEndSyncPayload> =
142155 )
143156 }
144157
145- const latestValue = await readCancelAtPeriodEnd ( payload . subscriptionId )
158+ const latestValue = await readDesiredCancelAtPeriodEnd ( ctx . eventId , payload . subscriptionId )
146159 if ( latestValue !== desiredValue ) {
147160 logger . info ( 'cancel_at_period_end changed during Stripe sync; retrying latest value' , {
148161 eventId : ctx . eventId ,
@@ -189,6 +202,10 @@ const stripeCancelSubscriptionImmediately: OutboxHandler<
189202 } )
190203}
191204
205+ /**
206+ * Pushes the seat count recorded on its own event, re-read each pass (falling back to the row
207+ * for an event enqueued before values were recorded), for the same reason as the cancel sync.
208+ */
192209const stripeSyncSubscriptionSeats : OutboxHandler < SubscriptionSeatsSyncPayload > = async (
193210 payload ,
194211 ctx
@@ -231,7 +248,7 @@ const stripeSyncSubscriptionSeats: OutboxHandler<SubscriptionSeatsSyncPayload> =
231248 return
232249 }
233250
234- const desiredSeats = row . seats || 1
251+ const desiredSeats = ( await readRecordedSyncValue ( ctx . eventId ) ) ?. seats ?? ( row . seats || 1 )
235252 const stripeSubscription = await stripe . subscriptions . retrieve ( row . stripeSubscriptionId )
236253
237254 if ( ! hasPaidSubscriptionStatus ( stripeSubscription . status ) ) {
@@ -281,7 +298,7 @@ const stripeSyncSubscriptionSeats: OutboxHandler<SubscriptionSeatsSyncPayload> =
281298 }
282299
283300 const latest = await getSubscriptionSeatSyncState ( payload . subscriptionId )
284- const latestSeats = latest ?. seats || 1
301+ const latestSeats = ( await readRecordedSyncValue ( ctx . eventId ) ) ?. seats ?? ( latest ?. seats || 1 )
285302 if ( latestSeats !== desiredSeats ) {
286303 logger . info ( 'Subscription seats changed during Stripe sync; retrying latest value' , {
287304 eventId : ctx . eventId ,
0 commit comments