agmission/Development/server/docs/PROMO_ENHANCEMENTS_V3.md

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 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:

# 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:

  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:

// 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

    // 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

    // 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

    // 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 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:

    # 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:

    // 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:

    // 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:

    // 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:

  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:

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:

# 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:

// 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:

  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:

[
  {
    "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

// 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):

  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):

{
  "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:

  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:

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

See Also