agmission/Development/server/docs/STRIPE_SUBSCRIPTION_SCHEDULE_LESSONS.md

12 KiB

Stripe Subscription Schedule Implementation Lessons

Last Updated: February 18, 2026
Context: Deferred promo application for addon quantity changes (v3.1)

Overview

This document captures critical lessons learned while implementing Stripe Subscription Schedules for deferred promotional discount application. These lessons prevent common pitfalls and ensure proper behavior.


Critical Lesson: proration_behavior: 'none' Required at TWO Levels

Problem Discovered (February 18, 2026)

When updating addon subscription quantity with a deferred 100% FREE promo:

  • Subscription update had proration_behavior: 'none' → No immediate charge
  • Subscription Schedule update was MISSING proration_behavior: 'none'
  • Result: Stripe created proration invoices and pending payments despite explicit no-proration setting

Root Cause

Stripe treats subscription updates and schedule updates as separate operations:

  1. Subscription Update with proration_behavior: 'none':

    • Prevents proration when updating subscription items directly
    • Does NOT affect schedule-initiated changes
  2. Subscription Schedule Update without proration_behavior:

    • Modifying current active phase (changing quantity) creates proration by default
    • Completely independent of subscription-level settings
    • Generates draft invoices with proration charges

Solution

ALWAYS set proration_behavior: 'none' in BOTH places:

// ✅ CORRECT: Set proration_behavior at both levels

// 1. When updating subscription directly
await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: itemId, quantity: newQty }],
  proration_behavior: 'none',  // ← Required for direct updates
  billing_cycle_anchor: 'unchanged'
});

// 2. When updating subscription schedule
await stripe.subscriptionSchedules.update(scheduleId, {
  proration_behavior: 'none',  // ← ALSO Required for schedule changes!
  phases: [
    {
      start_date: currentPhaseStart,
      items: [{ price: priceId, quantity: newQty }],
      end_date: currentPeriodEnd
    },
    {
      items: [{ price: priceId, quantity: newQty }],
      coupon: couponId
    }
  ]
});

Affected Code Locations (controllers/subscription.js):

  • Line 1075: Schedule update when updating existing schedule
  • Line 1114: Subscription update in deferred promo function
  • Line 1136: Schedule update when creating new schedule
  • Line 1899: Subscription update in standard update path

Verification

After implementing fix, confirmed:

  • No proration invoices created
  • No pending payments generated
  • No customer balance credits
  • Quantity changes immediately with $0.00 transaction
  • Promo applies from next billing period only

Expanded Schedule ID Type Handling

Problem Discovered (February 18, 2026)

When using expand: ['data.schedule'] in subscription list calls:

  • Stripe returns full schedule object, not just ID string
  • Passing object to subscriptionSchedules.update(schedule) fails:
    • Error: "Argument 'schedule' must be a string, but got: [object Object]"

Root Cause

Stripe's expand parameter changes return value type:

  • Without expand: subscription.schedule = "sched_xxx" (string)
  • With expand: subscription.schedule = { id: "sched_xxx", ... } (object)

APIs like subscriptionSchedules.update(), release(), cancel() expect string ID only.

Solution

Extract ID before passing to Stripe APIs:

// ✅ CORRECT: Handle both string and expanded object
const scheduleId = typeof subscription.schedule === 'string'
  ? subscription.schedule
  : subscription.schedule.id;

// Use extracted ID in all API calls
await stripe.subscriptionSchedules.update(scheduleId, {...});
await stripe.subscriptionSchedules.release(scheduleId);
await stripe.subscriptionSchedules.cancel(scheduleId);

// BONUS: Reuse expanded object to avoid redundant API call
const scheduleObj = typeof subscription.schedule === 'object'
  ? subscription.schedule  // Already have it!
  : await stripe.subscriptionSchedules.retrieve(scheduleId);

Affected Code Locations (controllers/subscription.js):

  • Lines 1061-1073: Deferred promo function - extract ID and reuse object
  • Lines 1871-1888: Standard update path - extract ID for release/cancel

Subscription Schedule Creation Patterns

Use Case: Adding schedule to subscription that doesn't have one

// Step 1: Update subscription quantity (NO proration)
const updatedSub = await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: itemId, quantity: newQty }],
  proration_behavior: 'none',
  billing_cycle_anchor: 'unchanged'
});

// Step 2: Create schedule from subscription
const schedule = await stripe.subscriptionSchedules.create({
  from_subscription: updatedSub.id
  // NOTE: Cannot combine from_subscription + phases in create()
});

// Step 3: Update schedule with phases
await stripe.subscriptionSchedules.update(schedule.id, {
  proration_behavior: 'none',  // ← CRITICAL!
  phases: [
    {
      start_date: schedule.current_phase.start_date,  // Use timestamp from created schedule
      items: [{ price: priceId, quantity: newQty }],
      end_date: currentPeriodEnd
    },
    {
      items: [{ price: priceId, quantity: newQty }],
      coupon: couponId
    }
  ]
});

Why This Pattern:

  • Preserves subscription state (trial, discount, etc.)
  • Stripe auto-populates first phase from subscription
  • Clean separation of subscription update and schedule management

Common Mistake: Trying to combine from_subscription + phases in create()Fails!

Pattern 2: Update Existing Schedule

Use Case: Subscription already has attached schedule

// Check if subscription has schedule (may be expanded object or string ID)
if (subscription.schedule) {
  const scheduleId = typeof subscription.schedule === 'string'
    ? subscription.schedule
    : subscription.schedule.id;
  
  const existingSchedule = typeof subscription.schedule === 'object'
    ? subscription.schedule  // Already expanded
    : await stripe.subscriptionSchedules.retrieve(scheduleId);
  
  // Update existing schedule phases
  await stripe.subscriptionSchedules.update(scheduleId, {
    proration_behavior: 'none',  // ← CRITICAL!
    phases: [
      {
        start_date: existingSchedule.current_phase.start_date,
        items: [{ price: priceId, quantity: newQty }],
        end_date: currentPeriodEnd
      },
      {
        items: [{ price: priceId, quantity: newQty }],
        coupon: couponId
      }
    ]
  });
}

Key Points:

  • Reuse existing schedule instead of creating new one
  • Use current_phase.start_date for accurate phase anchoring
  • Handle expanded schedule object to avoid redundant API call

Pattern 3: Release Schedule for Standard Updates

Use Case: Need to update subscription directly (no schedule needed)

if (subscription.schedule) {
  const scheduleId = typeof subscription.schedule === 'string'
    ? subscription.schedule
    : subscription.schedule.id;
  
  try {
    // Release subscription from schedule
    await stripe.subscriptionSchedules.release(scheduleId);
  } catch (releaseErr) {
    // Fallback: Cancel schedule if release fails
    try {
      await stripe.subscriptionSchedules.cancel(scheduleId);
    } catch (cancelErr) {
      // Log but don't block - subscription update may still work
    }
  }
}

// Now free to update subscription directly
await stripe.subscriptions.update(subscriptionId, {
  items: [...],
  proration_behavior: 'none'
});

Why Release First:

  • Subscriptions attached to schedules cannot be updated directly
  • Error: "cannot migrate a subscription that is already attached to a schedule"
  • Releasing detaches schedule and allows direct updates

Phase Configuration Best Practices

Phase Time Anchoring

CRITICAL: Always use actual Unix timestamps for phase boundaries:

// ✅ CORRECT
phases: [
  {
    start_date: existingSchedule.current_phase.start_date,  // Unix timestamp
    end_date: subscription.current_period_end,              // Unix timestamp
    items: [...]
  },
  {
    items: [...]  // Next phase starts automatically after previous ends
  }
]

// ❌ WRONG
phases: [
  {
    start_date: 'now',  // String not supported in updates!
    end_date: currentPeriodEnd,
    items: [...]
  }
]

Phase Metadata

Best Practice: Add metadata for debugging and tracking:

metadata: {
  deferred_promo: 'true',
  promo_coupon: couponId,
  original_quantity: oldQty,
  new_quantity: newQty,
  updated_at: Math.floor(Date.now() / 1000)
}

Why:

  • Helps debug schedule behavior in Stripe dashboard
  • Provides audit trail for quantity/promo changes
  • Can be used in webhooks for custom logic

Invoice Preview with Schedules

Dual Invoice Response

When deferred promo detected, return TWO invoices:

const response = {
  invoices: [
    {
      // Current period: Quantity change, NO promo
      type: 'current',
      period_type: 'current',
      amount_due: 24975,  // Prorated based on remaining period
      has_promo: false,
      metadata: { period_type: 'current' }
    },
    {
      // Next period: With 100% off promo
      type: 'next',
      period_type: 'next',
      amount_due: 0,
      discount: 24975,
      has_promo: true,
      promo_coupon: 'coup_xxx',
      metadata: {
        period_type: 'next',
        has_promo: 'true',
        promo_coupon: 'coup_xxx'
      }
    }
  ]
};

Preview Parameters for Future Period

IMPORTANT: Cannot use subscription_proration_date for future period preview:

// ❌ WRONG: subscription_proration_date only works for current period
const nextInv = await stripe.invoices.retrieveUpcoming({
  customer: custId,
  subscription: subId,
  subscription_proration_date: nextPeriodStart  // Ignored!
});

// ✅ CORRECT: Create schedule, then preview using schedule_details
const schedule = await stripe.subscriptionSchedules.create({...});
const nextInv = await stripe.invoices.retrieveUpcoming({
  customer: custId,
  schedule: schedule.id
  // Stripe automatically previews next phase
});

Common Errors and Solutions

Error: "cannot migrate a subscription that is already attached to a schedule"

Cause: Trying to update subscription directly when it has attached schedule

Solution: Release or update schedule instead

// Option 1: Release schedule, then update subscription
await stripe.subscriptionSchedules.release(scheduleId);
await stripe.subscriptions.update(subId, {...});

// Option 2: Update schedule phases instead
await stripe.subscriptionSchedules.update(scheduleId, { phases: [...] });

Error: "Argument 'schedule' must be a string, but got: [object Object]"

Cause: Passing expanded schedule object to API expecting ID string

Solution: Extract ID first (see "Expanded Schedule ID Type Handling" above)

Unexpected Proration Invoices

Cause: Missing proration_behavior: 'none' in schedule update

Solution: Add to ALL schedule updates that modify current phase:

await stripe.subscriptionSchedules.update(scheduleId, {
  proration_behavior: 'none',  // ← Required!
  phases: [...]
});

Testing Checklist

When implementing subscription schedules with deferred promos:

  • No proration invoices created when updating quantity
  • No pending payments generated
  • No customer balance credits
  • Quantity changes immediately
  • First phase has NO coupon
  • Second phase HAS coupon
  • Schedule phases have correct start/end dates
  • Invoice preview shows both current and next period
  • Current period invoice amount is $0 (or expected immediate charge)
  • Next period invoice shows 100% discount
  • Metadata properly populated for debugging
  • Schedule ID extraction handles both string and object
  • Release logic works when schedule exists
  • Rejects deferred promo for cancel_at_period_end: true

References