863 lines
29 KiB
Markdown
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
|