419 lines
12 KiB
Markdown
419 lines
12 KiB
Markdown
# 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`
|