Skip to content

Commit a5589d8

Browse files
committed
Handle subscription scheduled webhook events
1 parent 6770873 commit a5589d8

File tree

2 files changed

+202
-13
lines changed

2 files changed

+202
-13
lines changed

packages/billing/src/subscription-webhooks.ts

Lines changed: 181 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ export async function handleSubscriptionInvoicePaymentFailed(params: {
220220

221221
/**
222222
* Syncs plan details and cancellation intent from Stripe.
223+
*
224+
* Note: Downgrade scheduling is handled by subscription_schedule webhooks.
225+
* When a user downgrades via Customer Portal with "Wait until end of billing
226+
* period", Stripe creates a subscription schedule rather than immediately
227+
* changing the subscription price. The handleSubscriptionScheduleCreatedOrUpdated
228+
* handler sets scheduled_tier based on the schedule's phases.
223229
*/
224230
export async function handleSubscriptionUpdated(params: {
225231
stripeSubscription: Stripe.Subscription
@@ -259,22 +265,20 @@ export async function handleSubscriptionUpdated(params: {
259265

260266
const status = mapStripeStatus(stripeSubscription.status)
261267

262-
// Check existing tier to detect downgrades. During a downgrade the old
263-
// higher tier is kept in `scheduled_tier` so limits remain until renewal.
268+
// Check existing tier to detect upgrades for block grant expiration.
264269
const existingSub = await db
265270
.select({
266271
tier: schema.subscription.tier,
267-
scheduled_tier: schema.subscription.scheduled_tier,
268272
})
269273
.from(schema.subscription)
270274
.where(eq(schema.subscription.stripe_subscription_id, subscriptionId))
271275
.limit(1)
272276

273277
const existingTier = existingSub[0]?.tier
274-
const isDowngrade = existingTier != null && existingTier > tier
275278

276279
// Upsert — webhook ordering is not guaranteed by Stripe, so this event
277280
// may arrive before invoice.paid creates the row.
281+
// Note: We don't modify scheduled_tier here; that's managed by schedule webhooks.
278282
await db
279283
.insert(schema.subscription)
280284
.values({
@@ -296,11 +300,8 @@ export async function handleSubscriptionUpdated(params: {
296300
target: schema.subscription.stripe_subscription_id,
297301
set: {
298302
user_id: userId,
299-
// Downgrade: preserve current tier & stripe_price_id, schedule the
300-
// new tier for the next billing period.
301-
...(isDowngrade
302-
? { scheduled_tier: tier }
303-
: { tier, stripe_price_id: priceId, scheduled_tier: null }),
303+
tier,
304+
stripe_price_id: priceId,
304305
status,
305306
cancel_at_period_end: stripeSubscription.cancel_at_period_end,
306307
billing_period_start: new Date(
@@ -325,12 +326,9 @@ export async function handleSubscriptionUpdated(params: {
325326
{
326327
subscriptionId,
327328
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
328-
isDowngrade,
329329
isUpgrade,
330330
},
331-
isDowngrade
332-
? 'Processed subscription update — downgrade scheduled for next billing period'
333-
: 'Processed subscription update',
331+
'Processed subscription update',
334332
)
335333
}
336334

@@ -375,3 +373,173 @@ export async function handleSubscriptionDeleted(params: {
375373

376374
logger.info({ subscriptionId }, 'Subscription canceled')
377375
}
376+
377+
// ---------------------------------------------------------------------------
378+
// subscription_schedule.created / subscription_schedule.updated
379+
// ---------------------------------------------------------------------------
380+
381+
/**
382+
* Handles subscription schedule creation or updates.
383+
*
384+
* When a user schedules a downgrade via Stripe Customer Portal (with "Wait
385+
* until end of billing period"), Stripe creates a subscription schedule with
386+
* multiple phases. Phase 0 is the current state, phase 1+ contains the
387+
* scheduled changes.
388+
*
389+
* This handler extracts the scheduled tier from the next phase and stores it
390+
* in our database so we can show the pending change to the user and apply
391+
* appropriate limits at renewal.
392+
*/
393+
export async function handleSubscriptionScheduleCreatedOrUpdated(params: {
394+
schedule: Stripe.SubscriptionSchedule
395+
logger: Logger
396+
}): Promise<void> {
397+
const { schedule, logger } = params
398+
399+
// Only process active schedules
400+
if (schedule.status !== 'active') {
401+
logger.debug(
402+
{ scheduleId: schedule.id, status: schedule.status },
403+
'Ignoring non-active subscription schedule',
404+
)
405+
return
406+
}
407+
408+
// Get the linked subscription ID
409+
const subscriptionId = schedule.subscription
410+
? getStripeId(schedule.subscription)
411+
: null
412+
413+
if (!subscriptionId) {
414+
logger.warn(
415+
{ scheduleId: schedule.id },
416+
'Subscription schedule has no linked subscription — skipping',
417+
)
418+
return
419+
}
420+
421+
// Need at least 2 phases to have a scheduled change (current + future)
422+
if (!schedule.phases || schedule.phases.length < 2) {
423+
logger.debug(
424+
{ scheduleId: schedule.id, subscriptionId, phases: schedule.phases?.length },
425+
'Subscription schedule has fewer than 2 phases — no scheduled change',
426+
)
427+
return
428+
}
429+
430+
// Extract the scheduled tier from the next phase (phase 1)
431+
const nextPhase = schedule.phases[1]
432+
const scheduledPriceId = nextPhase?.items?.[0]?.price
433+
const priceId = typeof scheduledPriceId === 'string'
434+
? scheduledPriceId
435+
: scheduledPriceId?.toString()
436+
437+
if (!priceId) {
438+
logger.warn(
439+
{ scheduleId: schedule.id, subscriptionId },
440+
'Subscription schedule next phase has no price — skipping',
441+
)
442+
return
443+
}
444+
445+
const scheduledTier = getTierFromPriceId(priceId)
446+
if (!scheduledTier) {
447+
logger.debug(
448+
{ scheduleId: schedule.id, subscriptionId, priceId },
449+
'Scheduled price ID does not match a Strong tier — skipping',
450+
)
451+
return
452+
}
453+
454+
// Update the subscription with the scheduled tier
455+
const result = await db
456+
.update(schema.subscription)
457+
.set({
458+
scheduled_tier: scheduledTier,
459+
updated_at: new Date(),
460+
})
461+
.where(eq(schema.subscription.stripe_subscription_id, subscriptionId))
462+
.returning({ tier: schema.subscription.tier })
463+
464+
if (result.length === 0) {
465+
logger.warn(
466+
{ scheduleId: schedule.id, subscriptionId, scheduledTier },
467+
'No subscription found to update with scheduled tier — may arrive before subscription created',
468+
)
469+
return
470+
}
471+
472+
const currentTier = result[0]?.tier
473+
474+
logger.info(
475+
{
476+
scheduleId: schedule.id,
477+
subscriptionId,
478+
currentTier,
479+
scheduledTier,
480+
scheduledStartDate: nextPhase.start_date
481+
? new Date(nextPhase.start_date * 1000).toISOString()
482+
: null,
483+
},
484+
'Set scheduled tier from subscription schedule',
485+
)
486+
}
487+
488+
// ---------------------------------------------------------------------------
489+
// subscription_schedule.released / subscription_schedule.canceled
490+
// ---------------------------------------------------------------------------
491+
492+
/**
493+
* Handles subscription schedule release or cancellation.
494+
*
495+
* When a schedule is released (completes and detaches from the subscription)
496+
* or canceled (user cancels the pending change), we clear the scheduled_tier.
497+
*
498+
* Note: When a schedule "releases" after applying its final phase, the
499+
* subscription itself gets updated, which triggers invoice.paid at renewal.
500+
* That handler already clears scheduled_tier, but this provides a safety net.
501+
*/
502+
export async function handleSubscriptionScheduleReleasedOrCanceled(params: {
503+
schedule: Stripe.SubscriptionSchedule
504+
logger: Logger
505+
}): Promise<void> {
506+
const { schedule, logger } = params
507+
508+
const subscriptionId = schedule.subscription
509+
? getStripeId(schedule.subscription)
510+
: null
511+
512+
if (!subscriptionId) {
513+
logger.debug(
514+
{ scheduleId: schedule.id },
515+
'Released/canceled schedule has no subscription — skipping',
516+
)
517+
return
518+
}
519+
520+
const result = await db
521+
.update(schema.subscription)
522+
.set({
523+
scheduled_tier: null,
524+
updated_at: new Date(),
525+
})
526+
.where(eq(schema.subscription.stripe_subscription_id, subscriptionId))
527+
.returning({ tier: schema.subscription.tier })
528+
529+
if (result.length === 0) {
530+
logger.debug(
531+
{ scheduleId: schedule.id, subscriptionId },
532+
'No subscription found when clearing scheduled tier — may already be deleted',
533+
)
534+
return
535+
}
536+
537+
logger.info(
538+
{
539+
scheduleId: schedule.id,
540+
subscriptionId,
541+
status: schedule.status,
542+
},
543+
'Cleared scheduled tier after subscription schedule released/canceled',
544+
)
545+
}

web/src/app/api/stripe/webhook/route.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
handleSubscriptionInvoicePaymentFailed,
77
handleSubscriptionUpdated,
88
handleSubscriptionDeleted,
9+
handleSubscriptionScheduleCreatedOrUpdated,
10+
handleSubscriptionScheduleReleasedOrCanceled,
911
} from '@codebuff/billing'
1012
import db from '@codebuff/internal/db'
1113
import * as schema from '@codebuff/internal/db/schema'
@@ -390,6 +392,25 @@ const webhookHandler = async (req: NextRequest): Promise<NextResponse> => {
390392
}
391393
break
392394
}
395+
case 'subscription_schedule.created':
396+
case 'subscription_schedule.updated': {
397+
const schedule = event.data.object as Stripe.SubscriptionSchedule
398+
// Skip organization schedules (if they have org metadata)
399+
if (!schedule.metadata?.organization_id) {
400+
await handleSubscriptionScheduleCreatedOrUpdated({ schedule, logger })
401+
}
402+
break
403+
}
404+
case 'subscription_schedule.completed':
405+
case 'subscription_schedule.released':
406+
case 'subscription_schedule.canceled': {
407+
const schedule = event.data.object as Stripe.SubscriptionSchedule
408+
// Skip organization schedules (if they have org metadata)
409+
if (!schedule.metadata?.organization_id) {
410+
await handleSubscriptionScheduleReleasedOrCanceled({ schedule, logger })
411+
}
412+
break
413+
}
393414
case 'charge.dispute.created': {
394415
const dispute = event.data.object as Stripe.Dispute
395416

0 commit comments

Comments
 (0)