# 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: ` — 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: `, `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