agmission/Development/server/docs/STRIPE_SUBSCRIPTION_SCHEDULE_LESSONS.md

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`