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:
-
Subscription Update with
proration_behavior: 'none':- Prevents proration when updating subscription items directly
- Does NOT affect schedule-initiated changes
-
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
Pattern 1: Create from Existing Subscription (Recommended)
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_datefor 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
- Implementation:
controllers/subscription.jslines 1045-1156 (updateAddonWithDeferredPromo) - Documentation:
docs/PROMO_ENHANCEMENTS_V3.mdv3.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