@@ -10,6 +10,7 @@ import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
1010import {
1111 enqueueOutboxEvent ,
1212 listInflightOutboxEvents ,
13+ maxSettledOutboxPayloadNumber ,
1314 patchInflightOutboxEvents ,
1415} from '@/lib/core/outbox/service'
1516import type { DbOrTx } from '@/lib/db/types'
@@ -22,6 +23,8 @@ const logger = createLogger('BillingSubscriptionSync')
2223 * webhook is recognized as the echo of Sim's own sync rather than a change made in Stripe.
2324 */
2425const CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX = 'outbox-sync-cancel-at-period-end:'
26+ /** The key prefix the cancel-sync handler used before the one above; old pods still send it mid-rollout. */
27+ const LEGACY_SYNC_KEY_PREFIX = 'outbox:'
2528
2629const CANCEL_SYNC = OUTBOX_EVENT_TYPES . STRIPE_SYNC_CANCEL_AT_PERIOD_END
2730const SEATS_SYNC = OUTBOX_EVENT_TYPES . STRIPE_SYNC_SUBSCRIPTION_SEATS
@@ -161,20 +164,62 @@ export async function recommitSubscriptionSync(
161164 )
162165}
163166
167+ /**
168+ * Records a customer's restore through Better Auth's `/subscription/restore` as Sim's latest
169+ * committed `cancelAtPeriodEnd`. That endpoint updates Stripe and then writes the row directly,
170+ * so a cancel sync still in flight would otherwise carry the cancellation the customer just
171+ * undid, and a webhook processed before the restore's own could restore it for that sync to
172+ * push. Enqueuing re-pushes `false` even if such a sync already ran. Called from the endpoint's
173+ * `after` hook with the Stripe subscription it returned.
174+ */
175+ export async function commitCustomerRestoredSubscription ( restored : unknown ) : Promise < void > {
176+ const stripeSubscription = toRecord ( restored )
177+ const stripeSubscriptionId = stripeSubscription . id
178+ if (
179+ typeof stripeSubscriptionId !== 'string' ||
180+ stripeSubscription . cancel_at_period_end !== false
181+ ) {
182+ return
183+ }
184+
185+ await db . transaction ( async ( tx ) => {
186+ const [ row ] = await tx
187+ . select ( { id : subscription . id } )
188+ . from ( subscription )
189+ . where ( eq ( subscription . stripeSubscriptionId , stripeSubscriptionId ) )
190+ . for ( 'update' )
191+ . limit ( 1 )
192+ if ( ! row ) return
193+
194+ await tx
195+ . update ( subscription )
196+ . set ( { cancelAtPeriodEnd : false } )
197+ . where ( eq ( subscription . id , row . id ) )
198+ await enqueueCancelAtPeriodEndSync ( tx , {
199+ stripeSubscriptionId,
200+ subscriptionId : row . id ,
201+ cancelAtPeriodEnd : false ,
202+ reason : 'customer-restored' ,
203+ } )
204+ } )
205+ }
206+
164207/** A fresh key per Stripe write: the SDK reuses it across its own network retries of that call. */
165208export function cancelAtPeriodEndSyncIdempotencyKey ( eventId : string ) : string {
166209 return `${ CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX } ${ eventId } :${ generateShortId ( ) } `
167210}
168211
169212/**
170- * What a sync type's in-flight events say about its field: nothing in flight , the latest
213+ * What a sync type's in-flight events say about its field: no applicable value , the latest
171214 * committed value, or `legacy` when an event predates recorded values (enqueued by an older
172- * deploy) so the committed value is unknown.
215+ * deploy) so the committed value is unknown. An in-flight value older than one a settled event
216+ * already carried was revived by a retry path that did not re-commit, and does not apply.
173217 */
174218type InflightIntent < T > = { status : 'none' } | { status : 'legacy' } | { status : 'value' ; value : T }
175219
176220function latestIntent < T > (
177221 events : { eventType : string ; payload : unknown } [ ] ,
222+ settledCommittedAt : Map < string , number > ,
178223 eventType : SubscriptionSyncEventType ,
179224 readValue : ( payload : Record < string , unknown > ) => T | undefined
180225) : InflightIntent < T > {
@@ -188,20 +233,27 @@ function latestIntent<T>(
188233 latest = { committedAt : payload . committedAt , value }
189234 }
190235 }
191- return latest ? { status : 'value' , value : latest . value } : { status : 'none' }
236+ if ( ! latest || latest . committedAt < ( settledCommittedAt . get ( eventType ) ?? 0 ) ) {
237+ return { status : 'none' }
238+ }
239+ return { status : 'value' , value : latest . value }
192240}
193241
194242async function readInflightIntents ( executor : DbOrTx , subscriptionId : string ) {
195- const events = await listInflightOutboxEvents (
243+ const eventTypes = [ CANCEL_SYNC , SEATS_SYNC ]
244+ const subject = subscriptionSubject ( subscriptionId )
245+ const events = await listInflightOutboxEvents ( executor , eventTypes , subject )
246+ const settledCommittedAt = await maxSettledOutboxPayloadNumber (
196247 executor ,
197- [ CANCEL_SYNC , SEATS_SYNC ] ,
198- subscriptionSubject ( subscriptionId )
248+ eventTypes ,
249+ subject ,
250+ 'committedAt'
199251 )
200252 return {
201- cancelAtPeriodEnd : latestIntent ( events , CANCEL_SYNC , ( payload ) =>
253+ cancelAtPeriodEnd : latestIntent ( events , settledCommittedAt , CANCEL_SYNC , ( payload ) =>
202254 typeof payload . cancelAtPeriodEnd === 'boolean' ? payload . cancelAtPeriodEnd : undefined
203255 ) ,
204- seats : latestIntent ( events , SEATS_SYNC , ( payload ) =>
256+ seats : latestIntent ( events , settledCommittedAt , SEATS_SYNC , ( payload ) =>
205257 typeof payload . seats === 'number' ? payload . seats : undefined
206258 ) ,
207259 }
@@ -212,7 +264,10 @@ function isCancellationChangedInStripe(event: Stripe.Event): boolean {
212264 const previousAttributes = toRecord ( event . data . previous_attributes )
213265 if ( ! ( 'cancel_at_period_end' in previousAttributes ) ) return false
214266 const idempotencyKey = event . request ?. idempotency_key
215- return ! idempotencyKey ?. startsWith ( CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX )
267+ const issuedBySimSync =
268+ idempotencyKey ?. startsWith ( CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX ) ||
269+ idempotencyKey ?. startsWith ( LEGACY_SYNC_KEY_PREFIX )
270+ return ! issuedBySimSync
216271}
217272
218273type CancelAtPeriodEndSource =
0 commit comments