@@ -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 */
224230export 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+ }
0 commit comments