agmission/Development/server/docs/PROMO_ENHANCEMENTS_V3.md

863 lines
29 KiB
Markdown

# 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](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 by `PromoEligibility`
-**`'disabled'`** - Kill switch OFF, no automatic promos applied
**Rationale**:
- `PROMO_MODE` should 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**:
```bash
# 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**:
```javascript
// 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:
1. **Requires authentication** (no anonymous access for security)
2. **Automatically filters by customer eligibility** using authenticated user's customer ID
3. **Returns only eligible promos** based on subscription history cache
**Implementation**:
```javascript
// 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**:
1. **Type + PriceKey**: No two enabled promos can target same `type/priceKey` combination
```javascript
// Error Code: promo_duplicate_type_pricekey
// Error: "Active promo already exists for package/ess_1: 'First Package Free'"
```
2. **Coupon ID**: No two enabled promos can use same Stripe `couponId`
```javascript
// Error Code: promo_duplicate_coupon
// Error: "Active promo already uses coupon 50PCT_FIRST_YEAR: 'Returning Customer Discount'"
```
3. **Overlapping Valid Dates**: No two enabled promos for same `type/priceKey` can have overlapping `validUntil` periods
```javascript
// Error Code: promo_overlapping_dates
// Error: "Overlapping promo period for addon/addon_1: 'Holiday Special' (valid until 2026-12-31)"
```
**Implementation**:
```javascript
// 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`):
```javascript
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`:
```javascript
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**:
```json
{
"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**:
```json
{
"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 exists
- `promo_duplicate_coupon` - Same couponId already in use
- `promo_overlapping_dates` - Overlapping validUntil periods for same type/priceKey
---
## Migration Guide
### For Administrators
1. **Update Environment Variable**:
```bash
# Old (v2.0):
PROMO_MODE=all # or new_renew, or none
# New (v3.0):
PROMO_MODE=enabled # or disabled
```
2. **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
3. **Test Promo Eligibility**:
- Call `/api/activePromos` as authenticated user
- Verify only eligible promos are returned
- Test with both new and returning customers
### For Front-End Developers
1. **Authentication Required**:
```javascript
// Before (v2.0): Could call without auth
fetch('/api/activePromos')
// After (v3.0): MUST include auth token
fetch('/api/activePromos', {
headers: { 'Authorization': `Bearer ${token}` }
})
```
2. **No Client-Side Filtering Needed**:
```javascript
// 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
```
3. **Updated Mode Values**:
```javascript
// Before (v2.0):
if (currentMode.mode === 'none') { /* disabled */ }
// After (v3.0):
if (currentMode.mode === 'disabled') { /* disabled */ }
```
---
## Testing
### Test Scenarios
**Test 1: Auto-Eligibility Filtering**
```bash
# 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**
```bash
# 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**
```bash
# 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**:
1. `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)
2. `helpers/env.js` - Updated PROMO_MODE default to PromoModes.ENABLED
3. `controllers/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
4. `controllers/subscription.js`:
- Exported `checkPromoEligibility` and `hasSubscriptionHistory` functions
- 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
5. `model/customer.js`:
- Updated Stripe error handling to use StripeErrorTypes constants
6. `tests/test_setup_intent.js`:
- Updated Stripe error handling to use StripeErrorTypes constants
7. `docs/` - Updated this v3.0 documentation with new constants
**Test Files**:
- `tests/test_active_promos_eligibility.js` - Test auto-eligibility filtering
- `tests/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:
```javascript
fetch('/api/activePromos', {
headers: { 'Authorization': `Bearer ${userToken}` }
})
```
### Issue: "Active promo already exists" error when adding promo
**Cause**: Duplicate validation detected conflicting promo.
**Solution**:
1. Review existing promos for duplicates
2. Either disable existing promo or modify new promo to target different type/priceKey
3. 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:
```bash
# 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**:
1. Verify customer is authenticated when calling `/api/activePromos`
2. Check customer has Stripe customer ID (`customer.membership.custId`)
3. 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):
1. Customer upgrades addon quantity (e.g., 2 → 5 aircraft)
2. Backend auto-matches eligible 100% FREE promo from `subscriptionPromos`
3. Quantity changes **immediately** (no charge/refund)
4. 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:
```javascript
// 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):
```json
{
"addons": [
{ "price": "addon_1", "quantity": 5 }
]
}
```
**Backend Automatically**:
1. ✅ Queries `settings.subscriptionPromos` for eligible promos
2. ✅ Filters by customer eligibility (new/renew/all)
3. ✅ Matches by type (`addon`) and priceKey (e.g., `addon_1`)
4. ✅ Selects highest priority promo
5. ✅ Detects if coupon is 100% off (`percent_off: 100`)
6. ✅ 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:
```json
[
{
"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 `promoDetails` from `GET /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,
`pendingPromoDetails` is injected into those invoices too (via `pending_coupon_id` lookup)
**Client-side detection**:
- `next_billing_date` is always present — use it to display "next charge on" date
- When `pendingPromoDetails` is 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**
```javascript
// 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**
```javascript
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**
```javascript
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):
1. Validate subscription is not canceling (`cancel_at_period_end: false`)
2. Check if subscription already has a schedule (handles both new and existing schedules)
3. Update subscription quantity with `proration_behavior: 'none'` (if creating new schedule)
4. 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)
5. Write coupon display fields to **subscription metadata** (so the subscription list and invoice
preview endpoints can build `pendingPromoDetails` without expanding the schedule):
- `pending_coupon_id: <couponId>` — canonical indicator; presence = deferred promo is active
- `promo_name`, `promo_percent_off`, `promo_amount_off`, `promo_currency` — display fields
- `promo_duration`, `promo_duration_in_months` — coupon duration fields
Cleared (set to `null`) when the subscription is updated without a deferred promo.
6. 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_subscription` with `phases` in `create()` → Must create first, then update
- ✅ Phase updates require `start_date` anchor from `current_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**:
1. ✅ Addon subscription exists
2. ✅ Subscription status is `active` (not `trialing`, `past_due`, etc.)
3. ✅ Subscription has `cancel_at_period_end: false` (auto-renewing)
4.**Eligible promo auto-matched from `settings.subscriptionPromos`**
5.**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):
```json
{
"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)
```javascript
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)
```javascript
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**:
1. Queries `settings.subscriptionPromos` for eligible promos
2. Filters by customer eligibility (new_only/renew_only/all)
3. Matches by type (`addon`) and priceKey (e.g., `addon_1`)
4. Selects highest priority promo
5. If 100% off + active subscription → deferred promo pattern
6. If non-100% promo → immediate application
7. If no match → just quantity change
### Testing
**Test Script**: `tests/test_deferred_promo.js`
**Test Coverage** (5 scenarios):
1. ✅ Invoice preview shows deferred promo structure (2 invoices) with auto-matching
2. ✅ Schedule created with correct two-phase configuration
3. ✅ Subscription and schedule properly linked
4. ✅ No immediate charge detected ($0 transaction for quantity increase)
5. ✅ Deferred promo rejected for canceling subscriptions
**Key Test Validations**:
- Auto-matching from `settings.subscriptionPromos` works correctly
- 100% off detection triggers deferred pattern
- Non-100% promos use standard immediate application
- Subscription schedule phases configured correctly
- Subscription metadata contains `pending_coupon_id` and display fields after deferred update
- `pendingPromoDetails` present (full shape matching `promoDetails`) in invoice preview response
- `next_billing_date` present on all invoice objects in the response
**Run Tests**:
```bash
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:
```javascript
// 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](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](STRIPE_SUBSCRIPTION_SCHEDULE_LESSONS.md) - Complete API constraints and patterns
- **Testing Guide**: [PROMO_TESTING_GUIDE.md](PROMO_TESTING_GUIDE.md) - See "Deferred Promo Tests" section
---
## See Also
- [PROMO_ENHANCEMENTS_V2.md](PROMO_ENHANCEMENTS_V2.md) - Previous version details
- [PROMO_TESTING_GUIDE.md](PROMO_TESTING_GUIDE.md) - Testing scenarios
- [PROMO_MANAGEMENT.md](PROMO_MANAGEMENT.md) - Admin guide