agmission/Development/server/docs/PROMO_MANAGEMENT.md

32 KiB

Subscription Promo Management

Overview

The Subscription Promo Management system allows administrators to create promotional discounts for addon subscriptions. Promos use Stripe coupons with Subscription Schedules to provide time-limited free or discounted subscriptions that automatically transition to normal billing.

CRITICAL: SubscriptionSchedules create invoices in draft status without payment collection. The system automatically finalizes these invoices and verifies payment before activating subscriptions. See Payment Failure Handling for details on how failed payments are handled with promo subscriptions.

For Client Integration: See SUBSCRIPTION_PROMO_INTEGRATION.md for instructions on displaying applied promotion details to users.

Promotion Mode (Global Kill Switch)

Environment Variable: PROMO_MODE

Controls when automatic promotions are applied across the entire system. This is a global kill switch that affects:

  • /api/subscription/update - Subscription creation
  • /api/subscription/retrieveNextInvoices - Invoice preview
  • /api/activePromos - Public promo display

Modes (v3.0 Simplified)

Note: As of v3.0, PROMO_MODE has been simplified to just ON/OFF. Customer targeting is now 100% controlled by the eligibility field.

Mode Value Description Use Case
Enabled enabled Promotions enabled (targeting controlled by PromoEligibility) DEFAULT - Normal operation
Disabled disabled Never apply promotions (kill switch OFF) Emergency disable or maintenance

Deprecated Values (v2.0 and earlier):

  • all → Use enabled (customer targeting via eligibility field)
  • new_renew → Use enabled with eligibility='new_only' or 'renew_only'
  • none → Use disabled

See: PROMO_ENHANCEMENTS_V3.md for migration details

Behavior by Mode

enabled (Default)

Promotions are active. Customer targeting is controlled by each promo's eligibility field:

  • eligibility: 'all' - Any customer can use promo
  • eligibility: 'new_only' - Only first-time customers (no subscription history)
  • eligibility: 'renew_only' - Only returning customers (has subscription history)

Use Case: Normal operation with fine-grained control per promo.

disabled (Kill Switch)

Completely disables the promotion system:

  • Subscriptions are created without automatic promos
  • Invoice previews show full prices
  • /api/activePromos returns empty array
  • Admin endpoints still work (can view/edit promos)

Use Case: Emergency disable if promo system causes issues, or during maintenance.

Configuration

File: environment.env

PROMO_MODE=enabled  # Default in v3.0

Runtime: Can be overridden via environment variable:

export PROMO_MODE=disabled
node server.js

Constants (defined in helpers/constants.js):

const PromoModes = Object.freeze({
  ENABLED: 'enabled',
  DISABLED: 'disabled'
});

Admin Visibility

Admin promo endpoints (/api/admin/subscriptionPromos) include current mode in responses:

{
  "promos": [...],
  "currentMode": {
    "mode": "enabled",
    "description": "Promotions enabled (targeting controlled by PromoEligibility)",
    "isActive": true
  }
}

Features

Core Functionality

  • Create promos with 100% discount coupons for free subscriptions or partial discount coupons
  • Apply promos to subscriptions at creation time based on type and priceKey
  • Automatic expiry using Stripe SubscriptionSchedules with 2 phases
  • Trial precedence - trial periods always take priority over promo periods
  • Usage tracking - tracks how many subscriptions use each promo
  • Atomic operations - database transactions for data consistency
  • Email notifications - customers are notified when promos expire

How It Works

  1. Promo Creation: Admin creates a promo with name (i18n supported), description, coupon ID, and validity period
  2. Subscription with Promo: When a subscription is created with an active promo:
    • A 100% discount coupon is applied at creation (if current date < validUntil)
    • The coupon applies at each renewal billing date that occurs before discountEndsAt (falls back to validUntil for legacy promos)
    • A SubscriptionSchedule is created (to apply the coupon)
    • Schedule is immediately released - giving user direct control
    • cancel_at_period_end is set to true (default - user must opt-in to auto-renew)
    • The scheduleId and a snapshot of discountEndsAt are stored in subscription metadata
  3. Coupon Expiry: Once discountEndsAt (or validUntil fallback) passes, any renewal on or after that date charges the normal price (no discount)
  4. Billing Cycle Impact: The subscription's billing cycle day determines how many discounted renewals occur before discountEndsAt
  5. Trial Takes Precedence: If trial_end > validUntil, the subscription is created WITHOUT the promo/schedule
  6. User Control: User can toggle auto-renew at any time via standard cancel_at_period_end update

API Endpoints

Admin Endpoints

Method Endpoint Description Response Includes Mode
GET /admin/subscriptionPromos Get all promos Yes
GET /admin/subscriptionPromos/coupons Get all valid coupons (forever/repeating duration) from Stripe No
POST /admin/subscriptionPromos Replace all promos Yes
POST /admin/subscriptionPromos/add Add a new promo (validates coupon duration) Yes
PUT /admin/subscriptionPromos/:id Update a promo (updates Stripe schedules only if discountEndsAt changes; validUntil changes do NOT affect existing schedules) No (action result)
DELETE /admin/subscriptionPromos/:id Delete/disable a promo No (action result)

Admin Response Format (for list endpoints):

{
  "promos": [
    {
      "_id": "...",
      "name": "Addon Free Promo",
      "type": "addon",
      "enabled": true,
      "validUntil": "2026-12-31T23:59:59Z",
      "couponId": "FREE100",
      "usageCount": 42
    }
  ],
  "currentMode": {
    "mode": "enabled",
    "description": "Promotions enabled (targeting controlled by PromoEligibility)",
    "isActive": true
  }
}

Public Endpoint

Method Endpoint Description Respects PROMO_MODE
GET /activePromos Get active (valid, enabled) promos for display. Returns: type, priceKey, validUntil, name, nameKey, descriptionKey, discountType, discountValue, priority, eligibility, durationInMonths, chainable. Note: discountEndsAt and couponId are intentionally excluded — they are admin/internal fields. Yes (returns [] if mode='disabled')

Promo Schema

{
  _id: ObjectId,              // Auto-generated
  
  // Matching criteria
  type: String,               // 'package' or 'addon' (null = any)
  priceKey: String,           // Price lookup key e.g., 'addon_1', 'ess_1' (null = any)
  
  // Promo configuration
  couponId: String,           // Stripe coupon ID (100% off for free promos, or partial discount)
  validUntil: Date,           // Eligibility cutoff: last date NEW subscribers can apply this promo
                              // Does NOT affect when existing subscribers' discount ends
                              // Required for 'forever' coupons
  discountEndsAt: Date,       // Admin-set discount expiry date for EXISTING subscribers using this promo
                              // Applies to 'forever' coupons only — sets the schedule phase end_date
                              // For 'repeating' coupons: leave null (expiry is computed per-subscription
                              //   as subscription.start_date + durationInMonths, not known at promo level)
                              // Falls back to validUntil if not set (legacy promos)
                              // Updating this field propagates the new end_date to all active schedules
                              // ⚠️  NOT returned by /activePromos — internal/admin field only
  enabled: Boolean,           // Can be disabled without deletion (default: false)
  
  // Priority and eligibility (NEW in v2.0)
  priority: Number,           // Priority for multiple matching promos (higher = higher priority, default: 0)
  eligibility: String,        // Customer eligibility: 'all', 'new_only', 'renew_only' (default: 'all')
  chainable: Boolean,         // Future: Can be combined with other promos (default: false - NOT YET USED)
  durationInMonths: Number,   // For 'repeating' coupons: Number of months coupon applies (e.g., 12 for "first year")
                              // For 'forever' coupons: Leave null/undefined
  
  // Display (fallback)
  name: String,               // Fallback display name if no translation
  
  // i18n support (SCREAMING_SNAKE translation keys)
  nameKey: String,            // Translation key e.g., 'PROMO_ADDON_FREE'
  descriptionKey: String,     // Translation key e.g., 'PROMO_ADDON_FREE_DESC'
  
  // Discount info (for UI display)
  discountType: String,       // 'free', 'percent', or 'fixed'
  discountValue: Number,      // 100 for free, 50 for 50%, 500 for $5 off (cents)
  
  // Usage tracking
  usageCount: Number,         // Tracks active subscriptions using this promo (auto-updated)
                              // Incremented: When subscription created with promo
                              // Decremented: When subscription deleted OR promo period expires
  createdAt: Date             // Auto-set on creation
}

Promo Matching and Priority (v2.0)

When multiple promos match a subscription, the system uses a priority-based selection:

Sorting Logic (in order):

  1. Match Level: More specific matches win
    • Level 1 (Exact): type AND priceKey match → Most specific
    • Level 2 (Type): type matches, priceKey is null → Medium specific
    • Level 3 (Catchall): Both type and priceKey are null → Least specific
  2. Priority: Higher priority value wins (within same match level)
  3. Created Date: Older promos win (within same priority)

Example:

// Three promos for addon_1:
Promo A: { type: 'addon', priceKey: 'addon_1', priority: 5 }  // Level 1, priority 5
Promo B: { type: 'addon', priceKey: 'addon_1', priority: 10 } // Level 1, priority 10 ← WINNER
Promo C: { type: 'addon', priceKey: null, priority: 100 }     // Level 2, priority 100 (loses to level 1)

Customer Eligibility (v2.0)

The eligibility field controls which customers can use a promo:

Value Description Use Case
all Default - Any customer General promotions
new_only Only customers who have NEVER subscribed to this type/priceKey Customer acquisition ("First month free for new customers")
renew_only Only customers who HAVE subscribed to this type/priceKey before Retention/win-back ("Come back - 50% off!")

How It Works:

  • System queries SubscriptionHistory cache to check if customer has previous subscriptions
  • Cache built/synced via scripts/sync_subscription_history.js (from Stripe data)
  • Fail-open: If history check errors, promo is allowed (avoid blocking customers)

Note: eligibility is per-promo targeting, separate from global PROMO_MODE system control.

Coupon Duration Support (v2.0)

The system now supports two types of Stripe coupons:

Duration Promo Fields Behavior Use Case
forever validUntil required, durationInMonths null Uses SubscriptionSchedule with 2 phases (promo → normal) Time-limited promotions ("Free until March 2025")
repeating durationInMonths required, validUntil null Applied directly, Stripe auto-expires after N months Interval-limited promotions ("First 12 months 50% off")

Forever Coupons:

  • Admin sets validUntil (eligibility) and optionally discountEndsAt (absolute expiry for existing subscribers)
  • discountEndsAt is the same date for ALL subscribers of this promo (admin-configured absolute date)
  • System creates SubscriptionSchedule with 2 phases; phase end_date = discountEndsAt || validUntil
  • Phase 1: Coupon applied from now until discountEndsAt (or validUntil if discountEndsAt not set)
  • Phase 2: Normal billing after discountEndsAt

Repeating Coupons:

  • Stripe coupon has duration: 'repeating' + duration_in_months: 12
  • System reads durationInMonths from coupon metadata during promo creation
  • Coupon applied directly to subscription (no schedule needed)
  • Stripe automatically removes coupon after N billing cycles
  • discountEndsAt is NOT set on the promo — it's subscriber-specific: subscription.start_date + durationInMonths

Validation:

  • forever coupon + no validUntil → Error
  • repeating coupon + no durationInMonths in Stripe metadata → Error
  • once coupon → Not supported (error)

Invoice Preview with Automatic Promotions

Endpoint: POST /api/subscription/retrieveNextInvoices

Invoice previews automatically include promotions based on PROMO_MODE and promo eligibility:

Behavior (v3.0)

When PROMO_MODE = 'enabled' (default):

  • Promotions are active
  • Customer eligibility is checked per promo:
    • eligibility: 'all' → Shows promo pricing for all customers
    • eligibility: 'new_only' → Shows promo pricing only for first-time customers
    • eligibility: 'renew_only' → Shows promo pricing only for returning customers

When PROMO_MODE = 'disabled':

  • All previews show full pricing (no promos applied)

Request

{
  "custId": "cus_xxx",
  "package": "ess_1",
  "addons": [{"price": "addon_1", "quantity": 1}],
  "coupon": "SUMMER50"  // Can be coupon ID or promotion code (NEW)
}

Notes

  • NEW: coupon parameter accepts both coupon IDs and promotion codes (automatically resolved)
  • Explicit coupon parameter always takes precedence over automatic promos
  • Promo matching uses same logic as subscription creation (type + priceKey)
  • Supports both duration: 'forever' and 'repeating' coupons (uses CouponDuration constants)

Response

Always returns a flat JSON array of invoice objects (not wrapped in { "invoices": [...] }).

  • Standard (non-deferred): single invoice in the array
  • Deferred promo (100% off auto-matched for active, auto-renewing addon sub): two invoices:
    • period_type: 'current' — immediate preview with no promo; amount_due: 0 (proration_behavior: none)
    • period_type: 'next' — next billing period with promo applied

Convenience fields added by server (present on all invoice objects):

  • next_billing_date — Unix timestamp of next charge date
  • pendingPromoDetails — full promo shape (same as promoDetails from GET /subscription) when deferred promo is active; absent otherwise

Sanitized server-side: discount, discounts, and coupon fields are always removed before response.


Subscription Metadata

When a promo is applied, the following metadata is stored in both Stripe and local MongoDB:

Stripe Subscription Metadata

{
  type: 'addon',           // Subscription type
  promoId: String,         // MongoDB _id of the promo used
  scheduleId: String,      // Original Stripe SubscriptionSchedule ID (for reference)
                           // Note: Cleared when schedule is released
  // Deferred promo fields (written by updateAddonWithDeferredPromo, cleared when not deferred):
  pending_coupon_id: String,         // Canonical indicator: presence = deferred promo is active
  promo_name: String,                // Display name of the pending promo coupon
  promo_percent_off: String,         // e.g., '100'
  promo_amount_off: String,          // Fixed amount off (if any)
  promo_currency: String,            // Currency for amount_off
  promo_duration: String,            // 'forever' | 'repeating' | 'once'
  promo_duration_in_months: String   // N months for repeating, null otherwise
}

Local MongoDB Subscription Schema

// In customer.membership.subscriptions array (SubscriptionSchema)
{
  subscriptionId: String,  // Stripe subscription ID
  priceId: String,
  status: String,
  // ... other fields ...
  promoId: String,         // MongoDB _id of the promo used (matches Stripe metadata)
  scheduleId: String       // Stripe SubscriptionSchedule ID (null when schedule released)
}

Storage Strategy:

  • Both promoId and scheduleId stored in Stripe subscription metadata for API queries
  • NEW: Same fields also stored in local MongoDB for fast promo-based subscription queries
  • scheduleId is cleared (set to empty string in Stripe, null in MongoDB) when:
    • Schedule is released for cancel_at_period_end=true
    • Schedule becomes inactive
    • User cancels with inactive schedule

Benefits:

  • Query local subscriptions by promo: db.customers.find({'membership.subscriptions.promoId': promoId})
  • No stale schedule references after schedule release
  • Consistent data across Stripe and local DB

Webhook Handlers

The following Stripe webhook events are handled:

Event Handler Description
customer.subscription.deleted Webhook handler Decrements usageCount when subscription with promo is deleted
subscription_schedule.released handleSubscriptionScheduleReleased Schedule released - checks if immediate release (skips decrement) or promo ended (decrements usageCount, sends email)
subscription_schedule.completed handleSubscriptionScheduleCompleted All phases done (rare) - decrements usageCount, sends promo expired email
subscription_schedule.canceled (logging only) Schedule was canceled
coupon.deleted handleCouponDeleted NEW: Auto-disables promos using deleted coupon

Coupon Deleted Handling: When a coupon is deleted in Stripe:

  1. System finds all active promos using that coupon
  2. Sets enabled: false on each promo
  3. Sets validUntil: now to prevent new usage
  4. Logs each disabled promo
  5. Does NOT affect existing subscriptions - they keep their discount until canceled

Usage Count Tracking:

  • Incremented (+1): When subscription is created with promo applied
  • Decremented (-1): When subscription is deleted OR when promo period expires (schedule completed/released)
  • Not Decremented: When schedule is released within 60 seconds of creation (immediate release during subscription creation)

Immediate Release Detection: When a schedule is released within 60 seconds of creation, it's considered an "immediate release" (part of subscription creation with cancel_at_period_end: true). In this case, no promo expired email is sent and usageCount is NOT decremented (subscription is still using the promo). Only when the schedule is released after the promo period ends (due to reaching validUntil) will the promo expired email be sent and usageCount decremented.

Updating Promo Discount End Date

The two date fields have distinct roles:

Field Purpose Propagates to schedules?
validUntil Eligibility cutoff — last date NEW subscribers can apply this promo No
discountEndsAt When existing subscribers' discount expires (schedule phase end_date) Yes

Changing validUntil only gates future sign-ups. It does not shift the end_date of any active subscription schedule phase.

Changing discountEndsAt propagates the new date to all active schedules:

// Called when promo discountEndsAt is changed via PUT /admin/subscriptionPromos/:id
updatePromoSubscriptionSchedules(promoId, newDiscountEndsAt)

This also clears promoReminderSentAt on each updated subscription so the expiry reminder email will fire again near the new deadline.

Note: This function only updates active schedules (where user enabled auto-renew). Released schedules are skipped because:

  • Those subscriptions have cancel_at_period_end: true (default)
  • They will cancel at the billing period anyway
  • User has direct control over the subscription

Email Template

Template: promo-expired

Variables:

  • name - Customer name
  • promoName - The promo that ended
  • subType - Subscription product name
  • newBillingDate - Next billing date (formatted)
  • chargeAmount - Amount to be charged
  • manageSubUrl - Link to manage subscription

Trial Precedence Logic

Trials always take precedence over promos:

// Effective trial is the longest of:
// 1. Package subscription's remaining trial
// 2. Addon subscription's remaining trial  
// 3. params.trial_end (if provided)

if (effectiveTrialEnd > promoValidUntil) {
  // Skip promo entirely - create regular subscription without schedule/coupon
  // The trial provides longer free period than the promo
}

Environment Variables

Variable Default Description
PROMO_MIN_EXPIRY_DAYS 3 Minimum days before promo expiry for new subscriptions

CLI Scripts

Manage Promos

Use the admin API endpoints or create custom scripts:

# Get all promos
curl -X GET http://localhost:3000/admin/subscriptionPromos -H "Authorization: Bearer TOKEN"

# Add a promo
curl -X POST http://localhost:3000/admin/subscriptionPromos/add \
  -H "Content-Type: application/json" \
  -d '{
    "name": {"en": "Summer Special"},
    "description": {"en": "Free addon for summer 2025"},
    "couponId": "SUMMER_FREE_100",
    "validUntil": "2025-08-31"
  }'

# Disable a promo (if used) or delete (if unused)
curl -X DELETE http://localhost:3000/admin/subscriptionPromos/PROMO_ID

Error Codes

Promo-Specific Error Codes (v3.0)

Error Code Constant Description HTTP Status
promo_duplicate_type_pricekey Errors.PROMO_DUPLICATE_TYPE_PRICEKEY Active promo already exists for same type/priceKey 409
promo_duplicate_coupon Errors.PROMO_DUPLICATE_COUPON Active promo already uses this couponId 409
promo_overlapping_dates Errors.PROMO_OVERLAPPING_DATES Overlapping validUntil periods for same type/priceKey 409
promo_not_found Errors.PROMO_NOT_FOUND Promo with specified ID not found 409
promo_in_use_valid_until_required Errors.PROMO_IN_USE_VALID_UNTIL_REQUIRED Cannot delete promo with usage without validUntil 409
promo_valid_until_too_soon Errors.PROMO_VALID_UNTIL_TOO_SOON validUntil must be at least N days from now 409
promo_invalid_valid_until Errors.PROMO_INVALID_VALID_UNTIL Invalid validUntil date format 409

Example Response:

{
  "error": {
    ".tag": "promo_duplicate_type_pricekey",
    "message": "Active promo already exists for package/ess_1: 'First Package Free'"
  }
}

Constants (defined in helpers/constants.js):

const Errors = Object.freeze({
  // Promo error codes
  PROMO_DUPLICATE_TYPE_PRICEKEY: 'promo_duplicate_type_pricekey',
  PROMO_DUPLICATE_COUPON: 'promo_duplicate_coupon',
  PROMO_OVERLAPPING_DATES: 'promo_overlapping_dates',
  PROMO_NOT_FOUND: 'promo_not_found',
  // ...
});

Stripe Error Handling

Constants (defined in helpers/constants.js):

const StripeErrorTypes = Object.freeze({
  CARD_ERROR: 'StripeCardError',
  INVALID_REQUEST: 'StripeInvalidRequestError',
  API_ERROR: 'StripeAPIError',
  // ...
});

Usage:

const { StripeErrorTypes } = require('./helpers/constants');

if (stripeError.type === StripeErrorTypes.INVALID_REQUEST) {
  throw new AppParamError(Errors.INVALID_PARAM, `Invalid coupon: ${stripeError.message}`);
}

General Error Codes

Code Constant Description
promo_expired PROMO_EXPIRED Promo has passed validUntil date
promo_disabled PROMO_DISABLED Promo is disabled
promo_min_days PROMO_MIN_DAYS Promo expires too soon (< PROMO_MIN_EXPIRY_DAYS)

Default Behavior and Auto-Renew

New Design: Default Cancel at Period End

With the new promo system:

  • Default: cancel_at_period_end: true (user must opt-in to auto-renew)
  • Schedule is released immediately after creation
  • User has direct control over the subscription lifecycle
  • Coupon remains applied regardless of auto-renew setting

Schedule Release Flow

1. Create SubscriptionSchedule (applies coupon)
2. Release schedule immediately
3. Set cancel_at_period_end: true on subscription
4. User gets subscription with:
   - Coupon applied (100% off or partial discount)
   - Will cancel at billing period (default)
   - Can toggle auto-renew at any time

Toggling Auto-Renew (Simplified)

Since the schedule is released, toggling auto-renew works as follows:

Disable auto-renew (cancel_at_period_end: true):

// POST /api/subscription/setSubsSettings
{
  "subsSettings": [
    { "subId": "sub_xxx", "cancelAtPeriodEnd": true }   // Default behavior
  ]
}
  • If schedule exists: Release it, set cancel_at_period_end: true
  • If no schedule: Direct update to cancel_at_period_end: true

Enable auto-renew (cancel_at_period_end: false):

// POST /api/subscription/setSubsSettings
{
  "subsSettings": [
    { "subId": "sub_xxx", "cancelAtPeriodEnd": false }  // Enable auto-renew
  ]
}
  • If subscription is in TRIALING status:
    • Only updates cancel_at_period_end directly on subscription
    • No schedule is created or modified - trial must complete naturally
    • Schedule creation (for promo enforcement) happens automatically when trial ends and subscription becomes ACTIVE
  • If subscription is ACTIVE and has promoId in metadata and promo's validUntil is in the future:
    • Create a new 2-phase schedule to enforce validUntil (two-step process):
      1. Create schedule from existing subscription using from_subscription
      2. Update schedule with custom phases (Stripe API limitation)
    • Phase 1: Coupon applied until validUntil
    • Phase 2: No coupon (normal price)
  • If no promo or validUntil is past: Direct update to cancel_at_period_end: false

Key Points:

  • Trial subscriptions remain trials until trial_end - no schedule manipulation
  • Recreating schedules on ACTIVE subscriptions ensures the coupon properly expires at validUntil
  • Creating schedules on trial subscriptions would immediately activate them and charge customers (limitation by designed of Stripe subscriptionSchedules API!)

Technical Note: Stripe's API doesn't allow setting phases when using from_subscription, so we create the schedule first, then update it with our 2-phase configuration.

Implementation Files

  • Model: model/setting.js - Schema for subscriptionPromos
  • Routes: routes/main.js - API endpoint definitions
  • Controller: controllers/main.js - Promo CRUD operations
  • Subscription: controllers/subscription.js - Schedule creation, webhook handlers
  • Mailer: helpers/mailer.js - sendPromoExpiredEmail function
  • Email Template: emails/promo-expired/ - HTML and subject templates
  • Constants: helpers/constants.js - Error codes
  • Locales: locales/en.json - Translation keys for email

Best Practices

  1. Always create coupons in Stripe first before adding promos
  2. Set reasonable validUntil dates - at least PROMO_MIN_EXPIRY_DAYS in the future
  3. Use translation keys for multi-language support (nameKey, descriptionKey)
  4. Monitor webhook logs for schedule events
  5. Test with shorter validity periods in development

Testing Guide: Free Addon Promo (addon_1) until April 2026

Step 1: Create a 100% Off Coupon in Stripe

# Using Stripe CLI or Dashboard
stripe coupons create \
  --percent-off=100 \
  --duration=forever \
  --name="Free Aircraft Tracking until April 2026" \
  --id=ADDON1_FREE_APR2026

Or in Stripe Dashboard:

  1. Go to Products > Coupons > + New
  2. Set Percent off: 100%
  3. Set Duration: Forever (schedule handles expiry)
  4. Set ID: ADDON1_FREE_APR2026
  5. Click Create coupon

Step 2: Add the Promo via API

curl -X POST http://localhost:4100/admin/subscriptionPromos/add \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \
  -d '{
    "type": "addon",
    "priceKey": "addon_1",
    "name": "Free Aircraft Tracking",
    "nameKey": "PROMO_ADDON1_FREE",
    "descriptionKey": "PROMO_ADDON1_FREE_DESC",
    "couponId": "ADDON1_FREE_APR2026",
    "validUntil": "2026-04-30T23:59:59.000Z",
    "enabled": true,
    "discountType": "free",
    "discountValue": 100
  }'

Step 3: Verify Promo is Active

# Check all promos
curl http://localhost:4100/admin/subscriptionPromos \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN"

# Check active promos (public endpoint)
curl http://localhost:4100/activePromos

Step 4: Test Subscription Creation

  1. Create/Login as a test customer
  2. Subscribe to a package first (e.g., ess_1)
  3. Add addon_1 subscription
  4. Verify in Stripe Dashboard:
    • Subscription should have promoId and scheduleId in metadata
    • Coupon should be applied (100% off)
    • cancel_at_period_end should be true (default)
    • Schedule should be in released status
  5. Test Auto-Renew Toggle:
    • Call setSubsSettings with cancelAtPeriodEnd: false
    • Verify subscription's cancel_at_period_end changes to false

Step 5: Verify Webhook Handling (Optional)

# Start Stripe CLI webhook forwarding
stripe listen --forward-to localhost:4100/stPmtWH_EP

# In another terminal, trigger schedule events for testing
# (Note: Schedule events occur automatically at phase transitions)

Step 6: Test Promo Expiry Email (Mock)

To test the email without waiting until April 2026:

  1. Create a promo with a shorter validUntil (e.g., tomorrow)
  2. Use Stripe test clock to advance time
  3. Or manually call the email function in a test script:
// test_promo_email.js
const mailer = require('./helpers/mailer');

const testLocals = {
  name: 'Test User',
  promoName: 'Free Aircraft Tracking',
  subType: 'Aircraft Tracking',
  newBillingDate: 'May 1, 2026',
  chargeAmount: '$10.00',
  userId: 'test-user-id'
};

// Run with proper req object for baseUrl
mailer.sendPromoExpiredEmail(testLocals, 'test@example.com', null, { 
  protocol: 'https', 
  get: () => 'localhost:4100' 
});

Expected Results

Action Expected Result
Subscribe to addon_1 Subscription created with $0 invoice, coupon applied
Check subscription metadata Contains promoId and scheduleId (schedule was released)
Check subscription status cancel_at_period_end: true (default)
User enables auto-renew cancel_at_period_end: false, subscription continues
Subscription period ends (default) Subscription cancels at billing period
Subscription period ends (auto-renew ON) Customer charged normal rate, coupon still applies
Failed payment (test card 4000000000000341) Subscription not created, error returned with .tag: "payment_failed"
Partial discount (50% off) with failed card Same as above - subscription creation fails immediately

Cleanup (If Testing)

# Delete the test promo
curl -X DELETE http://localhost:4100/admin/subscriptionPromos/PROMO_ID \
  -H "Authorization: Bearer YOUR_ADMIN_TOKEN"

# Delete the coupon in Stripe (if no longer needed)
stripe coupons delete ADDON1_FREE_APR2026

Additional Documentation

For comprehensive client integration, including:

  • TypeScript/Angular and React code examples
  • Auto-renew toggle implementation
  • Subscription cancellation state detection
  • UI component examples

See docs/SUBSCRIPTION_PROMO_INTEGRATION.md