# 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**: ```javascript // ✅ 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**: ```javascript // ✅ 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 ### Pattern 1: Create from Existing Subscription (Recommended) **Use Case**: Adding schedule to subscription that doesn't have one ```javascript // 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 ```javascript // 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) ```javascript 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: ```javascript // ✅ 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: ```javascript 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: ```javascript 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: ```javascript // ❌ 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 ```javascript // 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: ```javascript 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 - **Implementation**: `controllers/subscription.js` lines 1045-1156 (`updateAddonWithDeferredPromo`) - **Documentation**: `docs/PROMO_ENHANCEMENTS_V3.md` v3.1 section - **Test Script**: `tests/test_deferred_promo.js` - **Stripe Docs**: https://stripe.com/docs/billing/subscriptions/subscription-schedules - **API Version**: `2025-01-27.acacia`