29 KiB
Subscription Promo Enhancements v3.0
Implementation Summary
This document summarizes the v3.0 enhancements that simplify promo management and add automatic eligibility filtering.
Implementation Date: January 28, 2026
Status: ✅ Complete
Previous Version: PROMO_ENHANCEMENTS_V2.md
Changes from v2.0
1. Simplified PROMO_MODE (Breaking Change)
Problem: PROMO_MODE had confusing values ('all', 'new_renew', 'none') that overlapped with PromoEligibility constants. Unclear separation of concerns.
Solution: Simplified PROMO_MODE to just ON/OFF:
- ✅
'enabled'(default) - Promotions enabled, targeting controlled byPromoEligibility - ✅
'disabled'- Kill switch OFF, no automatic promos applied
Rationale:
PROMO_MODEshould only control whether the promo system is ON or OFF- Customer targeting (new vs returning) is handled by
PromoEligibility('all','new_only','renew_only') - Eliminates confusion between "who can use promo" (eligibility) and "when to apply promo" (mode)
Migration:
# Before (v2.0):
PROMO_MODE=all # Apply to all subscriptions
PROMO_MODE=new_renew # Apply to new + renewals only
PROMO_MODE=none # Kill switch OFF
# After (v3.0):
PROMO_MODE=enabled # Promotions enabled (DEFAULT)
PROMO_MODE=disabled # Kill switch OFF
# Customer targeting now 100% controlled by promo.eligibility field
Constants Update:
// helpers/constants.js
const PromoModes = Object.freeze({
ENABLED: 'enabled', // Promotions enabled (targeting controlled by PromoEligibility)
DISABLED: 'disabled' // Kill switch: disable all automatic promos
});
// Stripe coupon duration types
const CouponDuration = Object.freeze({
FOREVER: 'forever', // Coupon applies indefinitely
REPEATING: 'repeating', // Coupon applies for N months
ONCE: 'once' // Coupon applies once (not supported in V2)
});
// Stripe error types
const StripeErrorTypes = Object.freeze({
CARD_ERROR: 'StripeCardError',
INVALID_REQUEST: 'StripeInvalidRequestError',
API_ERROR: 'StripeAPIError',
CONNECTION_ERROR: 'StripeConnectionError',
AUTHENTICATION_ERROR: 'StripeAuthenticationError',
RATE_LIMIT_ERROR: 'StripeRateLimitError'
});
Environment Default: helpers/env.js now defaults to PromoModes.ENABLED
2. Automatic Eligibility Filtering in /api/activePromos
Problem: Front-end received ALL active promos but couldn't determine which ones were eligible for current customer (e.g., eligibility='new_only' requires server-side subscription history check).
Solution: /api/activePromos endpoint now:
- Requires authentication (no anonymous access for security)
- Automatically filters by customer eligibility using authenticated user's customer ID
- Returns only eligible promos based on subscription history cache
Implementation:
// controllers/main.js - getActivePromos_get()
async function getActivePromos_get(req, res) {
// 1. Check PROMO_MODE kill switch
if (env.PROMO_MODE === PromoModes.DISABLED) {
return res.json({ promos: [], currentMode: getCurrentPromoModeInfo() });
}
// 2. Get customer ID from authenticated user
const custId = req.userInfo.puid; // Applicator's own ID or parent's ID
const customer = await Customer.findOne({ _id: ObjectId(custId) }).lean();
const stripeCustId = customer?.membership?.custId;
// 3. Filter for active promos (validUntil in future OR durationInMonths > 0)
const activePromos = (settings?.subscriptionPromos || [])
.filter(p => p.enabled && ((validDate && isAfter(now)) || (durationInMonths > 0)));
// 4. Check eligibility for each promo
const { checkPromoEligibility } = require('./subscription');
const eligiblePromos = [];
for (const promo of activePromos) {
const isEligible = await checkPromoEligibility(promo, stripeCustId, promo.type);
if (isEligible) {
eligiblePromos.push({ /* promo data */ });
}
}
return res.json({ promos: eligiblePromos, currentMode: getCurrentPromoModeInfo() });
}
Benefits:
- ✅ Front-end always gets correct list of eligible promos
- ✅ No client-side logic needed to filter by eligibility
- ✅ Secure - anonymous users can't access promo list
- ✅ Performance - uses local subscription history cache (no Stripe API calls)
Breaking Change: Anonymous users can no longer access /api/activePromos. Requires authentication.
3. Duplicate Promo Validation
Problem: No validation when adding promos - admins could create duplicate or conflicting promos.
Solution: addSubscriptionPromo_post now validates for duplicates:
Duplicate Checks:
-
Type + PriceKey: No two enabled promos can target same
type/priceKeycombination// Error Code: promo_duplicate_type_pricekey // Error: "Active promo already exists for package/ess_1: 'First Package Free'" -
Coupon ID: No two enabled promos can use same Stripe
couponId// Error Code: promo_duplicate_coupon // Error: "Active promo already uses coupon 50PCT_FIRST_YEAR: 'Returning Customer Discount'" -
Overlapping Valid Dates: No two enabled promos for same
type/priceKeycan have overlappingvalidUntilperiods// Error Code: promo_overlapping_dates // Error: "Overlapping promo period for addon/addon_1: 'Holiday Special' (valid until 2026-12-31)"
Implementation:
// controllers/main.js - addSubscriptionPromo_post()
const settings = await Settings.findOne({ userId: null }).lean();
const existingPromos = settings?.subscriptionPromos || [];
// Check 1: Duplicate type + priceKey
if (promoWithDate.type && promoWithDate.priceKey) {
const duplicate = existingPromos.find(p =>
p.type === promoWithDate.type &&
p.priceKey === promoWithDate.priceKey &&
p.enabled !== false
);
if (duplicate) {
throw new AppParamError(Errors.PROMO_DUPLICATE_TYPE_PRICEKEY,
`Active promo already exists...`);
}
}
// Check 2: Duplicate couponId
if (promoWithDate.couponId) {
const duplicate = existingPromos.find(p =>
p.couponId === promoWithDate.couponId &&
p.enabled !== false
);
if (duplicate) {
throw new AppParamError(Errors.PROMO_DUPLICATE_COUPON,
`Active promo already uses coupon...`);
}
}
// Check 3: Overlapping validUntil dates
if (promoWithDate.validUntil && promoWithDate.type && promoWithDate.priceKey) {
const overlapping = existingPromos.find(p =>
p.type === promoWithDate.type &&
p.priceKey === promoWithDate.priceKey &&
p.validUntil && p.enabled !== false &&
moment.utc(p.validUntil).isAfter(moment.utc()) &&
newValidUntil.isAfter(moment.utc())
);
if (overlapping) {
throw new AppParamError(Errors.PROMO_OVERLAPPING_DATES,
`Overlapping promo period...`);
}
}
Error Codes (defined in helpers/constants.js):
const Errors = Object.freeze({
// ... existing error codes ...
PROMO_DUPLICATE_TYPE_PRICEKEY: 'promo_duplicate_type_pricekey',
PROMO_DUPLICATE_COUPON: 'promo_duplicate_coupon',
PROMO_OVERLAPPING_DATES: 'promo_overlapping_dates',
});
Benefits:
- ✅ Prevents admin mistakes
- ✅ Avoids conflicting promo rules
- ✅ Clear error messages guide admins to fix issues
Exported Functions for Reuse
New Exports from controllers/subscription.js:
module.exports = {
// ... existing exports ...
// Eligibility checking functions (exported for reuse in other controllers)
checkPromoEligibility, // Check if customer is eligible for promo
hasSubscriptionHistory // Check if customer has subscription history
};
These functions can now be imported and used in other controllers (e.g., controllers/main.js for /api/activePromos).
Updated API Endpoints
GET /api/activePromos (Updated)
Changes:
- ✅ Now requires authentication (was public in v2.0)
- ✅ Automatically filters promos by customer eligibility
- ✅ Returns only promos eligible for authenticated user's customer account
- ✅ Returns empty array if
PROMO_MODE=disabled
Response:
{
"promos": [
{
"type": "addon",
"priceKey": "addon_1",
"validUntil": "2026-12-31T23:59:59.000Z",
"name": "First Addon Free",
"nameKey": "PROMO_FIRST_ADDON",
"descriptionKey": "PROMO_FIRST_ADDON_DESC",
"discountType": "free",
"discountValue": 100,
"priority": 10,
"eligibility": "new_only",
"durationInMonths": 3,
"chainable": false
}
],
"currentMode": {
"mode": "enabled",
"description": "Promotions enabled (targeting controlled by PromoEligibility)",
"isActive": true
}
}
Note: couponId is never exposed in response (security).
POST /api/admin/subscriptionPromos (Updated)
Changes:
- ✅ Now validates for duplicate promos before adding
- ✅ Checks type+priceKey, couponId, and overlapping validUntil dates
- ✅ Clear error messages for duplicate scenarios
Error Responses:
{
"error": {
".tag": "promo_duplicate_type_pricekey",
"message": "Active promo already exists for package/ess_1: 'First Package Free'"
}
}
All Duplicate Error Codes:
promo_duplicate_type_pricekey- Same type/priceKey combination existspromo_duplicate_coupon- Same couponId already in usepromo_overlapping_dates- Overlapping validUntil periods for same type/priceKey
Migration Guide
For Administrators
-
Update Environment Variable:
# Old (v2.0): PROMO_MODE=all # or new_renew, or none # New (v3.0): PROMO_MODE=enabled # or disabled -
Review Existing Promos:
- Check for duplicate type+priceKey combinations
- Check for duplicate couponIds
- Check for overlapping validUntil dates
- System will prevent new duplicates but existing ones remain
-
Test Promo Eligibility:
- Call
/api/activePromosas authenticated user - Verify only eligible promos are returned
- Test with both new and returning customers
- Call
For Front-End Developers
-
Authentication Required:
// Before (v2.0): Could call without auth fetch('/api/activePromos') // After (v3.0): MUST include auth token fetch('/api/activePromos', { headers: { 'Authorization': `Bearer ${token}` } }) -
No Client-Side Filtering Needed:
// Before (v2.0): Had to filter by eligibility on client const promos = await getActivePromos(); const eligiblePromos = promos.filter(p => { if (p.eligibility === 'new_only') return !hasHistory; if (p.eligibility === 'renew_only') return hasHistory; return true; }); // After (v3.0): Server already filtered, use directly const eligiblePromos = await getActivePromos(); // Already filtered -
Updated Mode Values:
// Before (v2.0): if (currentMode.mode === 'none') { /* disabled */ } // After (v3.0): if (currentMode.mode === 'disabled') { /* disabled */ }
Testing
Test Scenarios
Test 1: Auto-Eligibility Filtering
# Run test script:
node tests/test_active_promos_eligibility.js
# Expected: Returns only promos eligible for test customer
# - New customer: Gets 'new_only' and 'all' promos
# - Returning customer: Gets 'renew_only' and 'all' promos
Test 2: Duplicate Validation
# Run test script:
node tests/test_duplicate_promo_validation.js
# Expected: Rejects duplicates with clear error messages
# - Duplicate type+priceKey: "Active promo already exists..."
# - Duplicate couponId: "Active promo already uses coupon..."
# - Overlapping dates: "Overlapping promo period..."
Test 3: PROMO_MODE Simplified
# Test enabled mode:
PROMO_MODE=enabled node tests/test_promo_enhancements.js
# Test disabled mode:
PROMO_MODE=disabled node tests/test_promo_enhancements.js
# Expected: Disabled returns empty promos array
Code Changes Summary
Modified Files:
helpers/constants.js:- Simplified PromoModes to ENABLED/DISABLED
- Added CouponDuration constants (FOREVER, REPEATING, ONCE)
- Added StripeErrorTypes constants (CARD_ERROR, INVALID_REQUEST, etc.)
- Added new promo error codes (PROMO_DUPLICATE_TYPE_PRICEKEY, PROMO_DUPLICATE_COUPON, PROMO_OVERLAPPING_DATES)
helpers/env.js- Updated PROMO_MODE default to PromoModes.ENABLEDcontrollers/main.js:- Updated
getCurrentPromoModeInfo()for new modes - Updated
getActivePromos_get()for auto-eligibility filtering - Updated
addSubscriptionPromo_post()with duplicate validation using new error codes - Updated Stripe error handling to use StripeErrorTypes constants
- Updated
controllers/subscription.js:- Exported
checkPromoEligibilityandhasSubscriptionHistoryfunctions - Simplified promo application logic in
updateSubscriptions_post() - Simplified promo application logic in
retreiveNextInvoices() - Updated Stripe error handling to use StripeErrorTypes constants
- Updated coupon duration checks to use CouponDuration constants
- Exported
model/customer.js:- Updated Stripe error handling to use StripeErrorTypes constants
tests/test_setup_intent.js:- Updated Stripe error handling to use StripeErrorTypes constants
docs/- Updated this v3.0 documentation with new constants
Test Files:
tests/test_active_promos_eligibility.js- Test auto-eligibility filteringtests/test_duplicate_promo_validation.js- Test duplicate checking with new error codes
Troubleshooting
Issue: "Authentication required" error on /api/activePromos
Cause: Endpoint now requires authentication (v3.0 security enhancement).
Solution: Include authentication token in request:
fetch('/api/activePromos', {
headers: { 'Authorization': `Bearer ${userToken}` }
})
Issue: "Active promo already exists" error when adding promo
Cause: Duplicate validation detected conflicting promo.
Solution:
- Review existing promos for duplicates
- Either disable existing promo or modify new promo to target different type/priceKey
- Use different couponId if duplicate coupon detected
Issue: Promos not applying after upgrade to v3.0
Cause: PROMO_MODE still set to old values ('all', 'new_renew', 'none').
Solution: Update environment variable:
# In environment.env:
PROMO_MODE=enabled # (or 'disabled' to turn off)
Issue: Customer not seeing eligible promos
Cause: Subscription history cache may be stale or customer not authenticated.
Solution:
- Verify customer is authenticated when calling
/api/activePromos - Check customer has Stripe customer ID (
customer.membership.custId) - Run sync script to update history:
node scripts/sync_subscription_history.js --custId=CUSTOMER_ID
v3.1 Update: Deferred Promo Application (February 2026)
Implementation Date: February 17, 2026
Status: ✅ Complete
Overview
Added support for fully automatic deferred promotional discount application on addon quantity changes. The system automatically matches eligible promos from settings.subscriptionPromos and applies 100% FREE discounts from the next billing period.
How It Works (100% automatic):
- Customer upgrades addon quantity (e.g., 2 → 5 aircraft)
- Backend auto-matches eligible 100% FREE promo from
subscriptionPromos - Quantity changes immediately (no charge/refund)
- Promo applies from next billing period onwards
Client sends NO promo code - backend handles everything based on:
- Promo eligibility (new/renew/all customers)
- Type/priceKey matching
- Priority-based selection
- Auto-detection of 100% off coupons
Technical Implementation
Uses Stripe Subscription Schedules with two-phase management:
// Phase 1: Current Period
{
items: [{ price: 'addon_1', quantity: 5 }],
end_date: current_period_end,
coupon: null // No promo yet
}
// Phase 2: Next Period Onwards
{
items: [{ price: 'addon_1', quantity: 5 }],
coupon: 'FREE100' // Promo applied
}
API Changes
Fully Automatic (No Client Parameters)
Endpoint: POST /api/billing/subscriptions/update
Request (no promo parameter needed):
{
"addons": [
{ "price": "addon_1", "quantity": 5 }
]
}
Backend Automatically:
- ✅ Queries
settings.subscriptionPromosfor eligible promos - ✅ Filters by customer eligibility (new/renew/all)
- ✅ Matches by type (
addon) and priceKey (e.g.,addon_1) - ✅ Selects highest priority promo
- ✅ Detects if coupon is 100% off (
percent_off: 100) - ✅ Determines deferred vs immediate application automatically
Result:
- When 100% off promo matched + active subscription → deferred promo pattern (schedule-based)
- When non-100% promo or no match → standard immediate application
- When subscription is canceling → rejects deferred promo
No code changes needed in client - existing upgrade flows work automatically!
Enhanced Invoice Preview
Endpoint: POST /api/subscription/retrieveNextInvoices
Always returns a flat JSON array of Stripe invoice objects (not wrapped in an object).
Standard case (non-deferred promo or no promo): single invoice in the array.
Deferred promo case (active subscription + 100% off promo auto-detected): two invoice objects in the array:
[
{
"period_type": "current",
"has_promo": false,
"next_billing_date": 1748736000,
"amount_due": 0,
"subtotal": 24975,
"lines": { ... },
"customer": "cus_xxx",
"subscription": "sub_xxx",
"pendingPromoDetails": {
"isPending": true,
"appliesToNextPeriod": true,
"name": "FREE Month Promo",
"discountDisplay": "FREE",
"percentOff": 100,
"amountOff": null,
"currency": null,
"duration": "forever",
"durationInMonths": null,
"expiresAt": null,
"discountEndsAt": null,
"daysRemaining": null,
"daysUntilDiscountEnds": null,
"isTimeLimited": false
}
},
{
"period_type": "next",
"has_promo": true,
"next_billing_date": 1748736000,
"amount_due": 0,
"subtotal": 24975,
"amount_discount": 24975,
"lines": { ... },
"customer": "cus_xxx",
"subscription": "sub_xxx",
"pendingPromoDetails": {
"isPending": true,
"appliesToNextPeriod": true,
"name": "FREE Month Promo",
"discountDisplay": "FREE",
"percentOff": 100,
...
}
}
]
next_billing_date (Unix timestamp) — convenience field present on all invoice objects:
period_type: 'current'→addonSub.current_period_end(when promo period begins)period_type: 'next'→nextPeriodInvoice.period_start(when promo charge is collected)- Standard addon invoice →
invoice.period_end ?? addonSub.current_period_end - Package invoice →
invoice.period_end ?? packageSub.current_period_end
pendingPromoDetails — present on all invoice objects when a deferred promo is active:
- Same shape as
promoDetailsfromGET /subscription— clients use identical rendering - Set from the coupon already retrieved during processing (no extra API call at response time)
- On standard invoices for a subscription that has a pre-existing deferred promo in metadata,
pendingPromoDetailsis injected into those invoices too (viapending_coupon_idlookup)
Client-side detection:
next_billing_dateis always present — use it to display "next charge on" date- When
pendingPromoDetailsis present, the discount applies from the next billing period - Check
period_type === 'next'for the explicit next-period preview invoice discount/ coupon fields are always sanitized out server-side — never exposed to client
Implementation Details
Auto-Promo Matching and Detection Flow
Step 1: Auto-Match Eligible Promo
// In updateSubscriptions_post() - lines 1743-1792
let autoMatchedCouponId = resolvedCouponId; // Manual coupon takes precedence
if (!resolvedCouponId && env.PROMO_MODE !== PromoModes.DISABLED) {
const priceKey = getPriceKeyFromId(priceId);
// Call existing findMatchingPromo() from v3.0
const autoPromo = await findMatchingPromo(SubType.ADDON, priceKey, membership.custId);
if (autoPromo && autoPromo.couponId) {
const stripeCoupon = await stripe.coupons.retrieve(autoPromo.couponId);
if (stripeCoupon && !stripeCoupon.deleted) {
autoMatchedCouponId = autoPromo.couponId;
}
}
}
Step 2: Detect 100% Off for Deferred Application
let shouldUseDeferredPromo = false;
if (autoMatchedCouponId && addonSub.status === 'active' && !addonSub.cancel_at_period_end) {
const coupon = await stripe.coupons.retrieve(autoMatchedCouponId);
if (coupon.percent_off === 100) {
shouldUseDeferredPromo = true; // Trigger schedule-based pattern
}
}
Step 3: Apply Using Appropriate Pattern
if (shouldUseDeferredPromo) {
// Deferred Pattern: Subscription Schedule
await updateAddonWithDeferredPromo({
membership,
addonSub,
newQuantity,
couponId: autoMatchedCouponId,
priceId
});
} else if (autoMatchedCouponId) {
// Standard Pattern: Immediate application
subOps.coupon = autoMatchedCouponId;
await stripe.subscriptions.update(addonSub.id, subOps);
} else {
// No promo: Just quantity change
await stripe.subscriptions.update(addonSub.id, subOps);
}
Core Function: updateAddonWithDeferredPromo()
Process (lines 1045-1170):
- Validate subscription is not canceling (
cancel_at_period_end: false) - Check if subscription already has a schedule (handles both new and existing schedules)
- Update subscription quantity with
proration_behavior: 'none'(if creating new schedule) - Create or update SubscriptionSchedule with two-phase structure:
- Phase 1: Current period (new quantity, NO coupon)
- Phase 2: Next period onwards (new quantity, WITH 100% off coupon)
- Write coupon display fields to subscription metadata (so the subscription list and invoice
preview endpoints can build
pendingPromoDetailswithout expanding the schedule):pending_coupon_id: <couponId>— canonical indicator; presence = deferred promo is activepromo_name,promo_percent_off,promo_amount_off,promo_currency— display fieldspromo_duration,promo_duration_in_months— coupon duration fields Cleared (set tonull) when the subscription is updated without a deferred promo.
- Write tracking fields to schedule metadata:
deferred_promo: 'true',promo_coupon: <couponId>,original_quantity,new_quantity,updated_at
Key Stripe API Patterns Discovered:
- ❌ Cannot combine
from_subscriptionwithphasesincreate()→ Must create first, then update - ✅ Phase updates require
start_dateanchor fromcurrent_phase.start_date - ✅ Cannot use
start_date: 'now'→ Must use actual Unix timestamp - ✅ Invoice preview for future periods cannot use
subscription_proration_date
Full API constraints documented in docs/STRIPE_SUBSCRIPTION_SCHEDULE_LESSONS.md.
Validation Rules
Deferred promo is AUTOMATICALLY applied when ALL conditions met:
- ✅ Addon subscription exists
- ✅ Subscription status is
active(nottrialing,past_due, etc.) - ✅ Subscription has
cancel_at_period_end: false(auto-renewing) - ✅ Eligible promo auto-matched from
settings.subscriptionPromos - ✅ Matched coupon is 100% off (
percent_off: 100)
When any condition fails → Falls back to standard flow:
- Non-100% promo → Immediate application with proration
- No matched promo → Just quantity change
- Canceling subscription → Rejects with error
Error Response (for canceling subscriptions):
{
"error": {
".tag": "invalid_param",
"message": "Cannot apply deferred promo to subscription set to cancel at period end"
}
}
Frontend Integration
Client sends NO promo information - backend handles everything automatically!
Step 1: Preview Invoice (optional - shows both current and next period when 100% off detected)
const response = await fetch('/api/subscription/retrieveNextInvoices', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
custId: customerId,
addons: [{ price: 'addon_1', quantity: 5 }]
// NO promo parameter - backend auto-matches from settings.subscriptionPromos
})
});
const invoices = await response.json(); // flat array
// Backend automatically returns 2 invoices if it auto-matches 100% off promo:
// - invoices[0]: Current period charge (period_type: 'current')
// - invoices[1]: Next period with 100% off (period_type: 'next')
if (invoices.length === 2 && invoices[1].period_type === 'next') {
console.log('Today:', invoices[0].amount_due); // $0 (proration_behavior: none)
console.log('Next period:', invoices[1].amount_due); // $0 (100% off applied)
console.log('Next charge:', invoices[0].next_billing_date); // Unix timestamp
console.log('Promo:', invoices[0].pendingPromoDetails?.discountDisplay); // 'FREE'
}
Step 2: Apply Changes (just send new quantity)
await fetch('/api/billing/subscriptions/update', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
addons: [{ price: 'addon_1', quantity: 5 }] // price key + new quantity - backend handles promo matching and deferred application
})
});
Backend Automatic Behavior:
- Queries
settings.subscriptionPromosfor eligible promos - Filters by customer eligibility (new_only/renew_only/all)
- Matches by type (
addon) and priceKey (e.g.,addon_1) - Selects highest priority promo
- If 100% off + active subscription → deferred promo pattern
- If non-100% promo → immediate application
- If no match → just quantity change
Testing
Test Script: tests/test_deferred_promo.js
Test Coverage (5 scenarios):
- ✅ Invoice preview shows deferred promo structure (2 invoices) with auto-matching
- ✅ Schedule created with correct two-phase configuration
- ✅ Subscription and schedule properly linked
- ✅ No immediate charge detected ($0 transaction for quantity increase)
- ✅ Deferred promo rejected for canceling subscriptions
Key Test Validations:
- Auto-matching from
settings.subscriptionPromosworks correctly - 100% off detection triggers deferred pattern
- Non-100% promos use standard immediate application
- Subscription schedule phases configured correctly
- Subscription metadata contains
pending_coupon_idand display fields after deferred update pendingPromoDetailspresent (full shape matchingpromoDetails) in invoice preview responsenext_billing_datepresent on all invoice objects in the response
Run Tests:
node tests/test_deferred_promo.js
Critical Fix: Proration Prevention (February 18, 2026)
Problem Discovered: Even with proration_behavior: 'none' on subscription updates, Stripe was still creating proration invoices and pending payments when updating subscription schedules.
Root Cause: Stripe treats subscription updates and schedule updates as separate operations. Setting proration_behavior on subscription doesn't affect schedule-initiated changes.
Solution: Added proration_behavior: 'none' to BOTH subscription AND schedule update calls:
// 1. When updating subscription directly (lines 1114)
await stripe.subscriptions.update(addonSub.id, {
items: [{...}],
proration_behavior: 'none', // ← Prevents direct update proration
billing_cycle_anchor: 'unchanged'
});
// 2. When updating subscription schedule (lines 1075, 1136)
await stripe.subscriptionSchedules.update(scheduleId, {
proration_behavior: 'none', // ← ALSO REQUIRED! Prevents schedule proration
phases: [{...}]
});
Affected Code:
- ✅
updateAddonWithDeferredPromo()- lines 1075, 1114, 1136 - ✅ Standard update path - line 1899
Verification:
- ✅ No proration invoices created
- ✅ No pending payments
- ✅ No customer balance credits
- ✅ Quantity changes with $0.00 transaction
- ✅ Promo applies only from next billing period
Documentation: See STRIPE_SUBSCRIPTION_SCHEDULE_LESSONS.md for complete details on this and other Stripe API pitfalls.
Benefits
- ✅ Fully automatic - Client needs zero promo knowledge, just sends quantity changes
- ✅ No customer surprise charges - Quantity increases don't trigger immediate billing
- ✅ Flexible upgrade path - Customers can upgrade knowing promo starts next period
- ✅ Clean billing history - No prorated adjustments for promo application
- ✅ Centralized promo management - All promo logic in
settings.subscriptionPromos - ✅ Backward compatible - Existing upgrade flows work unchanged
- ✅ Manual coupon override - Admins can still apply specific coupons if needed
Related Documentation
- Stripe API Lessons: STRIPE_SUBSCRIPTION_SCHEDULE_LESSONS.md - Complete API constraints and patterns
- Testing Guide: PROMO_TESTING_GUIDE.md - See "Deferred Promo Tests" section
See Also
- PROMO_ENHANCEMENTS_V2.md - Previous version details
- PROMO_TESTING_GUIDE.md - Testing scenarios
- PROMO_MANAGEMENT.md - Admin guide