agmission/Development/server/docs/SUBSCRIPTION_PROMO_INTEGRATION.md

63 KiB

Subscription Promo System - Integration Guide

Quick reference for promotion system that makes subscriptions free/discounted for a period.


Applying Promotions to Subscriptions

Coupon ID vs Promotion Code

The /api/subscription/update endpoint accepts both coupon IDs and promotion codes (client-facing codes):

Coupon ID (Internal Stripe identifier):

  • Example: SUMMER50, FREE3MONTHS
  • Created in Stripe Dashboard → Products → Coupons
  • Direct reference to the discount configuration
  • Always accepted (no restrictions)

Promotion Code (Customer-facing code):

  • Example: WELCOME2026, NEWYEAR50
  • Created in Stripe Dashboard → Products → Promotion Codes
  • References a coupon but can have usage limits, expiry, first-time customer restrictions
  • ⚠️ FILTERED: Promotion codes with customer restrictions are rejected

Restriction Filtering: The system validates the following restrictions:

Promotion Code-Level Customer Restrictions:

  • promoCode.customer - Promotion code tied to a specific Stripe customer ID (validates customer match)
  • Note: Customer restrictions are ONLY available in the promotion code object, not in the coupon object

Promotion Code Restrictions:

  • restrictions.first_time_transaction: true - Code only for first-time customers (rejects all as we don't track this)

Product Restrictions (two levels):

  • restrictions.applies_to.products - Promotion code level product restrictions (checked first)
  • coupon.applies_to.products - Underlying coupon product restrictions (fallback if promo level not set)
    • System fetches the Price objects for the subscription (using price lookup keys like 'ess_1', 'addon_1')
    • Extracts the product IDs from those prices
    • Validates that at least one product ID matches the allowed products list
    • Note: Requires expanding applies_to field when retrieving coupon/promotion code

Validation Logic:

  • Customer restrictions: Validates that the current customer ID matches promoCode.customer (only for promotion codes)
  • Product restrictions: Checks promotion code restrictions first, then falls back to coupon restrictions
  • If validation fails, request is rejected with PROMO_INVALID_COUPON error and descriptive message

API automatically resolves both:

// Works with coupon ID
await api.post('/api/subscription/update', {
  package: 'ess_1',
  pmId: 'pm_xxx',
  coupon: 'SUMMER50'  // Coupon ID
});

// Also works with promotion code
await api.post('/api/subscription/update', {
  package: 'ess_1',
  pmId: 'pm_xxx',
  coupon: 'WELCOME2026'  // Promotion code - automatically resolved to coupon
});

Resolution Logic (optimized flow):

Step 1: Try as Promotion Code

  1. List promotion codes by code with expansion: expand: ['data.promotion.coupon', 'data.promotion.coupon.applies_to']
  2. If found, use the expanded coupon data (already includes applies_to)
  3. Store promotion code object for customer validation

Step 2: Try as Direct Coupon ID

  1. Retrieve coupon by ID with expansion: expand: ['applies_to']
  2. If successful, search for active promotion codes using this coupon
  3. If promotion code found, treat as promotion code (for restriction checking)
  4. If no promotion code, use direct coupon

Step 3: Validate Restrictions (only if code resolved successfully)

  1. First-time transaction: Check promoCode.restrictions.first_time_transaction → REJECT if true
  2. Customer restriction: Check promoCode.customer (only available in promotion code object) → Must match customer ID
  3. Product restrictions:
    • Check promoCode.restrictions.applies_to.products (promotion level) first
    • Fallback to coupon.applies_to.products if promotion level not set
    • Fetch price objects, extract product IDs, validate at least one matches
  4. Return coupon ID if all validations pass

Error Handling: Throws PROMO_INVALID_COUPON error with descriptive message if validation fails

Error Handling:

  • Invalid code returns 409 with promo_invalid_coupon error (constant: Errors.PROMO_INVALID_COUPON)
  • Specific error messages:
    • "Invalid coupon or promotion code: {code}" - Code not found
    • "Promotion code "{code}" is restricted to first-time customers only" - first_time_transaction restriction
    • "Promotion code "{code}" is not available for this customer" - Customer restriction
    • "Promotion code "{code}" is not applicable to the selected products" - Product restriction
    • "Promotion code "{code}" is restricted to specific products only" - Product restriction without priceKeys
  • Frontend should show user-friendly error message based on .tag value

Client Display of Applied Promotions

API Endpoint

GET /api/subscription/?custId={custId}&billInfo=true

Returns subscriptions with promoDetails object. Raw discount/coupon fields are removed for security (prevent coupon ID leakage).

Response Structure

{
  "id": "sub_xxx",
  "status": "active",
  "promoDetails": {
    "hasPromo": true,
    "name": "January Special",
    "discountDisplay": "50% OFF",                      // or "FREE", "$10.00 OFF"
    "expiresAt": "2026-01-31T23:59:59.000Z",          // When discount is removed (schedule-managed)
    "discountEndsAt": "2026-07-01T00:00:00.000Z",     // When discount stops for THIS subscription
    "daysRemaining": 15,                               // Days until expiresAt
    "daysUntilDiscountEnds": 147,                      // Days until discountEndsAt
    "isTimeLimited": true,                             // Has expiresAt or discountEndsAt
    "durationInMonths": 6,                             // Months for repeating coupons
    "duration": "repeating",                           // 'forever', 'once', or 'repeating'
    "percentOff": 50,                                  // Percentage discount
    "amountOff": null,                                 // Fixed amount in cents
    "currency": null                                   // Currency for amount_off
  }
}

Field Definitions

Core Fields:

  • hasPromo: Whether subscription has active promotion
  • name: Promotion display name
  • discountDisplay: Formatted discount string ("FREE", "50% OFF", "$10.00 OFF")

Expiry Fields (Two Concepts):

  • expiresAt: When discount is removed from THIS subscription (schedule-managed only) OR when coupon can no longer be redeemed
    • Populated from subscription.discount.end (SubscriptionSchedule) for schedule-managed promos
    • Populated from coupon.redeem_by for coupons with redemption deadline (no schedule)
    • For forever coupons with validUntil: equals when schedule removes coupon
    • For forever coupons with redeem_by: equals redeem_by date
    • For repeating coupons with redeem_by: shows when coupon closes to new subscribers
    • For repeating coupons: NULL (not schedule-managed, no redeem_by)
  • discountEndsAt: When discount stops applying to THIS subscription
    • For schedule-managed: same as expiresAt
    • For repeating: subscription start + durationInMonths
    • For once: 'applied' (special marker indicating one-time discount was used)
    • Shows actual last billing cycle with discount
  • daysRemaining: Days until expiresAt (NULL if no expiresAt)
  • daysUntilDiscountEnds: Days until discountEndsAt (NULL for once coupons)
  • isTimeLimited: True if has expiresAt OR discountEndsAt

Duration Fields:

  • duration: Stripe coupon type - 'forever', 'once', or 'repeating'
  • durationInMonths: Number of months for repeating coupons (NULL for forever/once)

Discount Values:

  • percentOff: Percentage discount (e.g., 50 for 50% off)
  • amountOff: Fixed amount in cents
  • currency: Currency for amountOff (e.g., 'usd')

Examples by Promo Type

1. Forever coupon with validUntil (Schedule-managed):

{
  "expiresAt": "2026-06-30T23:59:59.000Z",
  "discountEndsAt": "2026-06-30T23:59:59.000Z",
  "daysRemaining": 146,
  "daysUntilDiscountEnds": 146,
  "isTimeLimited": true,
  "duration": "forever",
  "durationInMonths": null
}

Meaning: Schedule removes coupon June 30. No future billing will have discount.

2. Repeating coupon (6 months) without validUntil: Subscribed Jan 1, 2026:

{
  "expiresAt": null,
  "discountEndsAt": "2026-07-01T00:00:00.000Z",
  "daysRemaining": null,
  "daysUntilDiscountEnds": 147,
  "isTimeLimited": true,
  "duration": "repeating",
  "durationInMonths": 6
}

Meaning: Promo always available for new subscribers. This subscriber gets discount until July 1 (6 billing cycles).

3. Repeating coupon (6 months) with promo validUntil: Promo validUntil = Mar 31, subscribed Jan 1:

{
  "expiresAt": null,
  "discountEndsAt": "2026-07-01T00:00:00.000Z",
  "daysRemaining": null,
  "daysUntilDiscountEnds": 147,
  "isTimeLimited": true,
  "duration": "repeating",
  "durationInMonths": 6
}

Meaning: Promo closes to new subscribers Mar 31, but this subscriber keeps discount until July 1. Note: expiresAt is NULL because promo's validUntil doesn't affect existing subscriptions.

4. Forever coupon without validUntil:

{
  "expiresAt": null,
  "discountEndsAt": null,
  "daysRemaining": null,
  "daysUntilDiscountEnds": null,
  "isTimeLimited": false,
  "duration": "forever",
  "durationInMonths": null
}

Meaning: Truly unlimited promotion with no expiry.

5. Forever coupon with redeem_by (no schedule):

{
  "expiresAt": "2026-12-31T23:59:59.000Z",
  "discountEndsAt": "2026-12-31T23:59:59.000Z",
  "daysRemaining": 330,
  "daysUntilDiscountEnds": 330,
  "isTimeLimited": true,
  "duration": "forever",
  "durationInMonths": null
}

Meaning: Coupon expires Dec 31 (can't be redeemed after this date). Existing subscribers keep discount forever, but no new redemptions after expiry.

6. Repeating coupon (6 months) with redeem_by: Subscribed Jan 1, redeem_by = Mar 31:

{
  "expiresAt": "2026-03-31T00:00:00.000Z",
  "discountEndsAt": "2026-07-01T00:00:00.000Z",
  "daysRemaining": 85,
  "daysUntilDiscountEnds": 177,
  "isTimeLimited": true,
  "duration": "repeating",
  "durationInMonths": 6
}

Meaning: Coupon closes to new subscribers Mar 31, but this subscriber keeps discount until July 1 (6 billing cycles from subscription start).

7. Once coupon (already applied):

{
  "expiresAt": null,
  "discountEndsAt": "applied",
  "daysRemaining": null,
  "daysUntilDiscountEnds": null,
  "isTimeLimited": true,
  "duration": "once",
  "durationInMonths": null
}

Meaning: One-time discount was applied to first invoice. Stripe removed it after first billing cycle.

Client Code Example

const subscriptions = await fetch(`/api/subscription/?custId=${custId}&billInfo=true`);
const sub = subscriptions[0];

if (sub.promoDetails.hasPromo) {
  // Show promo badge
  console.log(`Active Discount: ${sub.promoDetails.discountDisplay}`);
  
  // Show when discount ends for THIS subscription
  if (sub.promoDetails.discountEndsAt) {
    if (sub.promoDetails.discountEndsAt === 'applied') {
      // Once coupon - already used
      console.log('✓ One-time discount was applied to first invoice');
    } else {
      const daysLeft = sub.promoDetails.daysUntilDiscountEnds;
      
      if (daysLeft < 30) {
        // Urgent: Discount ending soon
        console.warn(`⚠️ Your discount ends in ${daysLeft} days!`);
        console.log(`Last discounted billing: ${sub.promoDetails.discountEndsAt}`);
        console.log(`Next charge after ${sub.promoDetails.discountEndsAt} will be full price`);
      } else {
        // Show expiry info
        console.log(`Discount valid until: ${sub.promoDetails.discountEndsAt}`);
        console.log(`${daysLeft} days remaining`);
      }
    }
  } else if (sub.promoDetails.duration === 'forever') {
    console.log('✓ Permanent discount - No expiration');
  }
  
  // Show duration for repeating coupons
  if (sub.promoDetails.duration === 'repeating' && sub.promoDetails.durationInMonths) {
    console.log(`Discount Duration: ${sub.promoDetails.durationInMonths} months`);
  }
}

Key Usage Notes:

  • Use discountEndsAt to show when discount stops for this customer
  • expiresAt is only for schedule-managed promos (rare)
  • For repeating coupons, discountEndsAt shows the actual last billing cycle
  • For forever coupons, check if discountEndsAt is NULL (unlimited)
  • For once coupons, discountEndsAt === 'applied' means one-time discount was used

Technical Note - Once Coupon Handling: Stripe automatically removes duration: 'once' coupons from subscriptions after the first invoice is paid. To retrieve these coupons, the system checks:

  1. subscription.discount.coupon - Active coupons (forever, repeating, or once before first billing)
  2. subscription.latest_invoice.discount.coupon - Fallback for once coupons already applied and removed

This requires expanding both fields:

expand: ['data.discount.coupon', 'data.latest_invoice.discount.coupon']

⚠️ Security: Never access sub.discount or sub.latest_invoice.discount - these fields don't exist in responses (removed to protect coupon IDs).


System Overview

Promotion Mode (PROMO_MODE) - v3.0 Simplified

Critical: All automatic promotion applications are controlled by the PROMO_MODE environment variable.

Mode Value Applies To Use Case
Enabled enabled Controlled by promo eligibility field DEFAULT - Normal operation
Disabled disabled Nothing Kill switch - Disable all promotions

v3.0 Change: Customer targeting is now controlled by each promo's eligibility field (all, new_only, renew_only), not by global PROMO_MODE.

Deprecated Values (v2.0):

  • all → Use enabled (customer targeting via eligibility)
  • new_renew → Use enabled with promo-specific eligibility
  • none → Use disabled

Affects:

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

Frontend Impact:

  • /api/activePromos returns { promos: [], currentMode: {...} } structure
  • When PROMO_MODE='disabled': promos array is empty, currentMode.isActive is false
  • Client can check currentMode.mode to adjust UI accordingly
  • Invoice previews always reflect current mode (show discounts or full price)

Example Response:

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

See PROMO_MANAGEMENT.md for full details.

When Promos Apply

Critical: Promo coupons are applied ONLY at specific moments:

  1. At subscription creation - When a new subscription is created and current date < validUntil
  2. At subscription renewals - The coupon discount applies only if the renewal/billing date occurs before validUntil
  3. After validUntil - Once the validUntil date passes, any renewal occurring on or after that date charges normal price (no discount)

Important: The promo is NOT continuously active - it only applies at creation and renewal billing dates. If a subscription's next billing date falls after validUntil, that billing will be at full price.

Example: If a promo has validUntil: April 30, 2026:

  • Subscription created March 15 (billing cycle: 15th of each month)
    • March 15 billing: $0 (coupon applied at creation)
    • April 15 billing: $0 (renewal date before validUntil )
    • May 15 billing: Full price (renewal date after validUntil )
  • Subscription created April 20 (billing cycle: 20th of each month)
    • April 20 billing: $0 (coupon applied at creation)
    • May 20 billing: Full price (first renewal after validUntil )

Key Insight: A subscription created just before validUntil may only get one discounted billing (creation), while one created earlier gets multiple discounted renewals.

How It Works

flowchart TB
    subgraph existing["LIVE SUBSCRIPTIONS"]
        A[pause_addon_subs.js] --> B[Stripe API<br/>pause_collection]
        B --> C[Auto-resumes on date]        
    end
    
    subgraph new["NEW SUBSCRIPTIONS with SubscriptionSchedules"]
        D[createSubscription] --> E[findMatchingPromo]
        E --> F{Promo found?}
        F -->|Yes| G[Create SubscriptionSchedule<br/>with coupon applied]
        G --> G2[Release Schedule Immediately<br/>Set cancel_at_period_end: true]
        F -->|No| H[Normal billing<br/>no schedule needed]
    end

SubscriptionSchedules Architecture

When a promo is applied to a new subscription, we use Stripe SubscriptionSchedules to automatically remove the coupon at the validUntil date without requiring cron jobs or manual intervention.

Key Design Principle: Separation of Concerns

  • Schedule controls COUPON DURATION (when the discount ends)
  • User's cancel_at_period_end controls SUBSCRIPTION LIFECYCLE (whether to auto-renew)

Coupon Application Timeline:

  • The coupon is applied when the subscription is created (if current date < validUntil)
  • The coupon applies at each renewal billing date that occurs before validUntil
  • Once validUntil is reached, any renewal on or after that date charges the normal price
  • Billing cycle matters: A subscription created on the 1st of the month has different renewal dates than one created on the 20th
sequenceDiagram
    participant Client
    participant Server
    participant Stripe
    
    Note over Client,Stripe: CREATE SUBSCRIPTION WITH PROMO (default: cancel at period end)
    Client->>Server: Create subscription (type: addon)
    Server->>Server: findMatchingPromo() → promo with couponId
    Server->>Stripe: stripe.subscriptionSchedules.create()
    Note right of Stripe: Phase 1: coupon applied until validUntil<br/>Phase 2: no coupon (normal billing)<br/>end_behavior: 'release'
    Stripe-->>Server: Schedule created with subscription
    Server->>Stripe: stripe.subscriptionSchedules.release()
    Note right of Stripe: Schedule released immediately<br/>Subscription now standalone<br/>Coupon still applied!
    Server->>Stripe: stripe.subscriptions.update()<br/>cancel_at_period_end: true
    Server->>Server: Increment promo.usageCount
    Server-->>Client: Subscription active (will cancel at period end)
    
    Note over Client,Stripe: USER ENABLES AUTO-RENEW
    Client->>Server: setSubsSettings(cancelAtPeriodEnd: false)
    Server->>Stripe: Update subscription directly
    Note right of Stripe: cancel_at_period_end: false<br/>Coupon continues, subscription auto-renews

Why SubscriptionSchedules with Immediate Release?

  • Coupon is applied during schedule creation
  • Schedule release gives user direct control over subscription
  • cancel_at_period_end: true (default) allows easy opt-out
  • User can toggle auto-renew at any time without schedule complexity
  • No cron jobs required - coupon stays until manually removed

Note: With the new design, schedules are released immediately after creation. This means subscription_schedule.completed events are rare. The webhook handler for subscription_schedule.released checks if the release happened within 60 seconds of creation - if so, it's an "immediate release" during subscription creation and no promo expired email is sent. Only releases that happen after the promo period (when validUntil is reached) will trigger the email.

Trial vs Promo Precedence

When a subscription is in trial, the trial always takes precedence over the promo's validUntil date. This ensures customers get their full trial period.

flowchart TD
    A[createSubscription with promo] --> B{Check trial_end}
    B --> C[Get effective trial_end<br/>Priority: Package > Addon > params]
    C --> D{trial_end > validUntil?}
    D -->|Yes| E[Skip SubscriptionSchedule<br/>Apply coupon directly<br/>Trial continues normally]
    D -->|No| F[Create SubscriptionSchedule<br/>Phase 1: coupon until validUntil<br/>Phase 2: normal billing]
    
    style E fill:#90EE90
    style F fill:#87CEEB

Trial Check Priority:

  1. Package subscription's trial_end - checked first (takes precedence)
  2. Addon subscription's trial_end - checked if no package trial
  3. params.trial_end - fallback if trialConfByType not available

Behavior:

  • If trial_end > validUntil: No schedule created, coupon applied directly, trial continues until trial_end
  • If trial_end <= validUntil: Schedule created with 2 phases, trial included in phase 1

Promo Lifecycle

stateDiagram-v2
    [*] --> Active: New subscription created
    Active --> Paused: pause_addon_subs.js
    Paused --> Active: Auto-resume date reached
    Paused --> Active: resume_addon_subs.js
    Active --> [*]: Subscription cancelled
    
    note right of Paused
        No invoices generated
        Customer not charged
    end note

System Architecture

flowchart LR
    subgraph client["Client App"]
        UI[Front-End UI]
    end
    
    subgraph server["Server"]
        API["/api/activePromos"]
        Admin["/api/admin/*"]
        SubCtl[subscription.js]
    end
    
    subgraph db["Database"]
        Settings[(Settings<br/>subscriptionPromos)]
    end
    
    subgraph stripe["Stripe"]
        StripeAPI[Stripe API]
        Coupons[Coupons]
    end
    
    UI -->|GET| API
    API --> Settings
    Admin --> Settings
    SubCtl -->|findMatchingPromo| Settings
    SubCtl -->|Apply coupon| StripeAPI
    StripeAPI --> Coupons

Payment Failure Handling

CRITICAL SECURITY: When subscriptions are created with promo coupons using SubscriptionSchedules, special payment verification is required to prevent unauthorized free access.

The Challenge

SubscriptionSchedules create invoices in draft status without attempting payment. This means:

  • Subscription appears active immediately
  • Invoice is created but not finalized
  • No payment attempt is made
  • Customer has "free" access without valid payment method

The Solution

The system automatically:

  1. Finalizes draft invoices created by SubscriptionSchedules
  2. Verifies payment status after finalization
  3. Cancels subscriptions if payment fails or requires action
  4. Returns error to prevent unauthorized access

Payment Failure Statuses

The following payment intent statuses indicate failure:

Status Description Action
requires_payment_method Payment failed, needs valid card Cancel subscription
requires_action Requires 3D Secure authentication Cancel subscription
requires_confirmation Payment intent needs confirmation Cancel subscription

Error Response

When payment fails during subscription creation:

{
  "error": {
    ".tag": "payment_failed",
    "message": "Payment failed. Please add a valid payment method."
  }
}

Testing Payment Failures

Use Stripe test cards to verify failure handling:

// Card that always fails
const failedCard = '4000000000000341'; // Generic decline

// Expected behavior:
// 1. Subscription creation attempted
// 2. Invoice finalized
// 3. Payment fails
// 4. Subscription immediately canceled
// 5. Error returned to client
// 6. No subscription created in database

Client-Side Handling

try {
  const response = await api.post('/api/subscription/update', {
    package: 'addon_1',
    pmId: 'pm_card_declined',
    coupon: 'PROMO50'  // Can be coupon ID or promotion code (client-facing)
  });
  
  // Success: subscription created
  handleSubscriptionCreated(response.data);
  
} catch (error) {
  if (error.response?.data?.error?.['.tag'] === 'payment_failed') {
    // Show payment failed message
    showError('Payment failed. Please check your payment method and try again.');
    redirectToPaymentMethod();
  } else if (error.response?.data?.error?.['.tag'] === 'promo_invalid_coupon') {
    // Invalid coupon/promotion code or restricted
    const message = error.response?.data?.error?.message || 'Invalid promotion code';
    showError(message); // Shows specific restriction message if applicable
  }
}

Additional Documentation

For complete implementation details, see:


API Endpoints

Public Endpoints

GET /api/subscription/getCoupon/:coupon

NEW: Now accepts both coupon IDs and promotion codes (client-facing). Validates restrictions against authenticated user (if logged in). Optional query parameter: priceKeys - comma-separated price lookup keys (e.g., ?priceKeys=ess_1,addon_1)

Purpose: Retrieve coupon details with validation against user and product context.

Parameters:

  • coupon - Can be either a coupon ID (e.g., SUMMER50) or promotion code (e.g., WELCOME2026)

Query Parameters:

  • priceKeys (optional) - Comma-separated price lookup keys for product validation (e.g., ess_1,addon_1)

Authentication:

  • Optional: If user is authenticated, validates customer restrictions
  • Without authentication: Rejects coupons with customer restrictions

Response:

{
  "id": "SUMMER50",
  "name": "50% OFF Summer Sale",
  "percent_off": 50,
  "duration": "repeating",
  "duration_in_months": 3,
  "valid": true
}

Error Response (Customer Not Matching):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Coupon \"VIP2026\" is not available for this customer"
  }
}

Error Response (Expired Coupon):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Coupon expired on 2026-01-31T23:59:59.000Z"
  }
}

Error Response (Max Redemptions Reached):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Coupon has reached maximum redemption limit"
  }
}

Error Response (First-Time Transaction Restricted):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Promotion code \"FIRST50\" is restricted to first-time customers only"
  }
}

Error Response (Product Not Matching):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Coupon \"ENTERPRISE50\" is not applicable to the selected products"
  }
}

Error Response (Product Restricted - No Context):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Coupon \"PRODUCT50\" is restricted to specific products only"
  }
}

Error Response (Invalid):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Invalid coupon or promotion code: INVALID123"
  }
}

Example Usage:

# Without authentication or price context
GET /api/subscription/getCoupon/SUMMER50

# With authenticated user (validates customer restrictions)
GET /api/subscription/getCoupon/VIP2026
Authorization: Bearer <token>

# With price keys (validates product restrictions)
GET /api/subscription/getCoupon/ENT50?priceKeys=ent_1,addon_1

# With both (full validation)
GET /api/subscription/getCoupon/PROMO2026?priceKeys=ess_1
Authorization: Bearer <token>

GET /api/activePromos

Returns active promos for front-end display. No authentication required.

Response:

[
  {
    "type": "addon",
    "priceKey": "addon_1",
    "validUntil": "2026-04-30T00:00:00.000Z",
    "name": "Addon Free Until April 2026",
    "nameKey": "PROMO_ADDON_FREE",
    "descriptionKey": "PROMO_ADDON_FREE_DESC",
    "discountType": "free",
    "discountValue": 100
  }
]

Notes:

  • Only returns promos where enabled: true and validUntil is in the future
  • Does NOT expose couponId (sensitive info)
  • nameKey and descriptionKey are i18n keys (SCREAMING_SNAKE) for Angular translation
  • If nameKey translation is missing, fall back to name

Admin Endpoints (Requires System Admin Auth)

GET /api/admin/subscriptionPromos

Returns all promo rules with full details.

Response:

[
  {
    "type": "addon",
    "priceKey": "addon_1",
    "enabled": true,
    "validUntil": "2026-04-30T00:00:00.000Z",
    "couponId": "FREE_ADDON_100",
    "name": "Addon Free Until April 2026",
    "nameKey": "PROMO_ADDON_FREE",
    "descriptionKey": "PROMO_ADDON_FREE_DESC",
    "discountType": "free",
    "discountValue": 100,
    "createdAt": "2025-12-01T10:00:00.000Z"
  }
]

GET /api/admin/subscriptionPromos/coupons

Returns all valid Stripe coupons with duration='forever' or 'repeating' for promo creation. Excludes 'once' duration coupons (not supported).

Response:

[
  {
    "id": "PROMO50",
    "name": "50% Off Forever",
    "percent_off": 50,
    "duration": "forever",
    "valid": true,
    "created": 1640995200
  },
  {
    "id": "FREE100",
    "name": "100% Free Forever",
    "percent_off": 100,
    "duration": "forever",
    "valid": true,
    "created": 1640995300
  }
]

Notes:

  • Returns coupons with duration='forever' or 'repeating' (uses CouponDuration constants)
  • Excludes coupons with duration='once' (not supported in v2+)
  • See helpers/constants.js for CouponDuration constants
  • Use this endpoint to populate coupon selection dropdown in admin UI

POST /api/admin/subscriptionPromos

Replace all promo rules (bulk update).

Request:

{
  "promos": [
    {
      "type": "addon",
      "priceKey": "addon_1",
      "enabled": true,
      "validUntil": "2026-04-30T00:00:00.000Z",
      "couponId": "FREE_ADDON_100",
      "name": "Addon Free Until April 2026",
      "nameKey": "PROMO_ADDON_FREE",
      "descriptionKey": "PROMO_ADDON_FREE_DESC",
      "discountType": "free",
      "discountValue": 100
    }
  ]
}

POST /api/admin/subscriptionPromos/add

Add a single promo rule. Validates that coupon exists and has duration='forever' or 'repeating'. Also validates for duplicates (type/priceKey, couponId, overlapping dates).

Request:

{
  "type": "addon",
  "priceKey": "addon_1",
  "enabled": true,
  "validUntil": "2026-04-30T00:00:00.000Z",
  "couponId": "FREE_ADDON_100",
  "name": "Addon Free Until April 2026",
  "nameKey": "PROMO_ADDON_FREE",
  "descriptionKey": "PROMO_ADDON_FREE_DESC",
  "discountType": "free",
  "discountValue": 100
}

Validation:

  • Checks that coupon exists in Stripe
  • Accepts duration='forever' or 'repeating' - rejects coupons with duration='once' (not supported)
  • Validates for duplicates - checks type+priceKey, couponId, and overlapping validUntil dates
  • Uses CouponDuration and StripeErrorTypes constants from helpers/constants.js

Error Response (Invalid Coupon Duration):

{
  "error": {
    ".tag": "promo_invalid_coupon",
    "message": "Only coupons with duration='forever' or 'repeating' are supported. Coupon XYZ has duration='once'"
  }
}

Duplicate Validation Errors (v3.0):

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

PUT /api/admin/subscriptionPromos/:id

Update a promo rule. If validUntil changes and promo has been used, all related Stripe SubscriptionSchedules are updated.

Request:

{
  "validUntil": "2026-06-30T00:00:00.000Z",
  "name": "Extended: Free Addon Until June 2026",
  "enabled": true
}

Response:

{
  "action": "updated",
  "promo": {
    "_id": "6829a1b2c3d4e5f6a7b8c9d0",
    "name": "Extended: Free Addon Until June 2026",
    "validUntil": "2026-06-30T00:00:00.000Z",
    "enabled": true,
    "usageCount": 15
  },
  "schedulesUpdated": 15,
  "schedulesFailed": 0
}

Allowed Fields:

  • name, nameKey, descriptionKey, description
  • validUntil - If shortened with usage, must be at least PROMO_MIN_EXPIRY_DAYS in future
  • enabled - Toggle promo on/off
  • discountType, discountValue

Note: Cannot update type, priceKey, or couponId after creation.

Error Codes: promo_not_found, promo_valid_until_too_soon, promo_invalid_valid_until, promo_duplicate_type_pricekey, promo_duplicate_coupon, promo_overlapping_dates

See: CONSTANTS_REFERENCE.md for complete error code reference

Note: All errors return HTTP 409 with { tag: 'error_code', message: 'Error message' } format.

DELETE /api/admin/subscriptionPromos/:id

Delete or disable a promo rule by its MongoDB _id.

  • If promo has never been used (usageCount === 0): Deletes the promo permanently
  • If promo has been used (usageCount > 0): Requires validUntil in request body with minimum grace period
  • Uses Stripe SubscriptionSchedules to remove coupon at validUntil date

Environment Variable:

  • PROMO_MIN_EXPIRY_DAYS - Minimum days before promo can expire (default: 3)

Example Request (no usage - just DELETE):

DELETE /api/admin/subscriptionPromos/6829a1b2c3d4e5f6a7b8c9d0

Example Request (has usage - requires validUntil):

DELETE /api/admin/subscriptionPromos/6829a1b2c3d4e5f6a7b8c9d0
Content-Type: application/json

{
  "validUntil": "2026-04-30T00:00:00.000Z"
}

Response when promo is DELETED (no usage):

{
  "action": "deleted",
  "promo": { "_id": "6829a1b2c3d4e5f6a7b8c9d0", "name": "Free Addon Promo" }
}

Error Codes (returned in .tag field):

v3.0 - Duplicate Validation Errors:

Error Code Description HTTP Status
promo_duplicate_type_pricekey Active promo already exists for same type/priceKey 409
promo_duplicate_coupon Active promo already uses this couponId 409
promo_overlapping_dates Overlapping validUntil periods for same type/priceKey 409

General Promo Errors:

Error Code Description HTTP Status
promo_not_found Promo with specified ID does not exist 409
promo_in_use_valid_until_required Promo has usage, validUntil date required to disable 409
promo_valid_until_too_soon validUntil is less than PROMO_MIN_EXPIRY_DAYS from now 409
promo_invalid_valid_until Invalid validUntil date format 409

Response when promo is DISABLED (has usage, valid validUntil):

{
  "action": "disabled",
  "promo": {
    "_id": "6829a1b2c3d4e5f6a7b8c9d0",
    "name": "Free Addon Promo",
    "usageCount": 15,
    "validUntil": "2026-04-30T00:00:00.000Z",
    "couponId": "FREE_ADDON_100",
    "enabled": false
  },
  "schedulesUpdated": 12,
  "schedulesFailed": 0
}

Schedule Update Details:

  • schedulesUpdated: Number of Stripe SubscriptionSchedules successfully updated to new validUntil
  • schedulesFailed: Number of schedules that failed to update
  • scheduleErrors: Array of error details (only present if there were failures)

When a promo is disabled, the server automatically updates all Stripe SubscriptionSchedules that use this promo to ensure the coupon is removed at the new validUntil date.

sequenceDiagram
    participant Admin
    participant Server
    participant MongoDB
    participant Stripe
    
    Admin->>Server: DELETE /api/admin/subscriptionPromos/:id<br/>{ validUntil: "2026-04-30" }
    Server->>MongoDB: Find promo by ID
    MongoDB-->>Server: promo (usageCount: 15)
    Server->>MongoDB: Disable promo, set validUntil
    Server->>Stripe: List subscriptions with promoId
    Stripe-->>Server: 15 subscriptions
    
    loop Each subscription with schedule
        Server->>Stripe: Update schedule phase 2 start to validUntil
    end
    
    Server-->>Admin: { action: "disabled", schedulesUpdated: 12 }

Front-End Integration

TypeScript/Angular Example

// promo.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';

export interface ActivePromo {
  type: 'package' | 'addon';
  priceKey: string;
  validUntil: string;
  name: string;                    // Fallback display name
  nameKey?: string;                // i18n key e.g., 'PROMO_ADDON_FREE'
  descriptionKey?: string;         // i18n key e.g., 'PROMO_ADDON_FREE_DESC'
  discountType?: 'free' | 'percent' | 'fixed';
  discountValue?: number;          // 100 for free, 50 for 50%, etc.
}

@Injectable({ providedIn: 'root' })
export class PromoService {
  constructor(
    private http: HttpClient,
    private translate: TranslateService
  ) {}

  getActivePromos(): Observable<ActivePromo[]> {
    return this.http.get<ActivePromo[]>('/api/activePromos');
  }

  getAddonPromo(): Observable<ActivePromo | undefined> {
    return this.getActivePromos().pipe(
      map(promos => promos.find(p => p.type === 'addon'))
    );
  }

  /**
   * Get translated promo name with fallback
   */
  getPromoName(promo: ActivePromo): string {
    if (promo.nameKey) {
      const translated = this.translate.instant(promo.nameKey);
      // If translation key not found, ngx-translate returns the key itself
      if (translated !== promo.nameKey) return translated;
    }
    return promo.name;  // Fallback
  }
}

Component Usage

// subscription.component.ts
export class SubscriptionComponent implements OnInit {
  addonPromo: ActivePromo | null = null;

  constructor(
    private promoService: PromoService,
    public translate: TranslateService
  ) {}

  ngOnInit() {
    this.promoService.getAddonPromo().subscribe(promo => {
      this.addonPromo = promo || null;
    });
  }
}

Template (with i18n)

<!-- Show promo banner if addon promo is active -->
<div *ngIf="addonPromo" class="promo-banner">
  <!-- Use nameKey with translate pipe, fallback to name -->
  <strong>{{ addonPromo.nameKey ? (addonPromo.nameKey | translate) : addonPromo.name }}</strong>
  
  <!-- Show discount info -->
  <p *ngIf="addonPromo.discountType === 'free'">100% Free</p>
  <p *ngIf="addonPromo.discountType === 'percent'">{{ addonPromo.discountValue }}% Off</p>
  <p *ngIf="addonPromo.discountType === 'fixed'">{{ addonPromo.discountValue / 100 | currency }} Off</p>
  
  <p>Valid until {{ addonPromo.validUntil | date:'mediumDate' }}</p>
</div>

Translation File (en.json)

{
  "PROMO_ADDON_FREE": "Free Aircraft Tracking",
  "PROMO_ADDON_FREE_DESC": "Enjoy {{value}}% off until {{until}}",
  "PROMO_PACKAGE_50_OFF": "50% Off All Packages",
  "PROMO_PACKAGE_50_OFF_DESC": "Save {{value}}% on any package subscription"
}

React Example

// useActivePromos.ts
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';  // or your i18n lib

interface ActivePromo {
  type: 'package' | 'addon';
  priceKey: string;
  validUntil: string;
  name: string;
  nameKey?: string;
  descriptionKey?: string;
  discountType?: 'free' | 'percent' | 'fixed';
  discountValue?: number;
}

export function useActivePromos() {
  const [promos, setPromos] = useState<ActivePromo[]>([]);
  const [loading, setLoading] = useState(true);
  const { t } = useTranslation();

  useEffect(() => {
    fetch('/api/activePromos')
      .then(res => res.json())
      .then(data => {
        setPromos(data);
        setLoading(false);
      });
  }, []);

  const addonPromo = promos.find(p => p.type === 'addon');
  
  return { promos, addonPromo, loading };
}

// Component
function SubscriptionPage() {
  const { addonPromo, loading } = useActivePromos();

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      {addonPromo && (
        <div className="promo-banner">
          <h3>{addonPromo.name}</h3>
          <p>Valid until {new Date(addonPromo.validUntil).toLocaleDateString()}</p>
        </div>
      )}
      {/* Rest of subscription UI */}
    </div>
  );
}

Subscription Cancellation State & Auto-Renew Toggle

New Design: Default Cancel at Period End

With the new promo system, new subscriptions default to cancel_at_period_end: true (opt-out). Users must explicitly enable auto-renew to continue billing after the first period.

Key Change: The schedule is released immediately after creation, giving users direct control over the subscription. The coupon remains applied but the user controls whether to auto-renew.

Subscription Response Fields

getCustSubscriptions returns raw Stripe subscription objects with these key fields:

Field Type Description
schedule string | null Usually null (schedule released)
cancel_at_period_end boolean Whether subscription cancels at billing period
current_period_end number Unix timestamp of billing period end
metadata.scheduleId string Original schedule ID (cleared when released)
metadata.promoId string The promo rule ID that was applied
discount object | null Applied coupon info

Local MongoDB Mirror: The same promoId and scheduleId are also stored in customer.membership.subscriptions[] for fast local queries.

Determining Cancellation State (Simplified)

// TypeScript helper - now much simpler!
interface Subscription {
  cancel_at_period_end: boolean;
  current_period_end: number;
  metadata?: {
    scheduleId?: string;
    promoId?: string;
  };
  discount?: { coupon: { id: string } } | null;
}

function willSubscriptionCancel(sub: Subscription): boolean {
  // Simple! Just check the standard Stripe field
  return sub.cancel_at_period_end;
}

function getCancellationDate(sub: Subscription): Date | null {
  if (sub.cancel_at_period_end) {
    return new Date(sub.current_period_end * 1000);
  }
  return null;
}

function hasPromo(sub: Subscription): boolean {
  return !!sub.metadata?.promoId || !!sub.discount;
}

Behavior Summary

Scenario cancel_at_period_end discount Will Cancel? What Happens
New promo sub (default) true Coupon applied Yes Cancels at billing period end
Promo sub (auto-renew ON) false Coupon applied No Auto-renews with coupon discount
Regular sub Varies None Depends Normal Stripe behavior

Toggling Auto-Renew

Use setSubsSettings API to toggle auto-renew. Same API for all subscriptions (promo and regular):

// Disable auto-renew (cancel at period end) - DEFAULT for new promo subs
await api.post('/api/setSubsSettings', {
  subsSettings: [{
    subId: 'sub_xxx',
    cancelAtPeriodEnd: true
  }]
});

// Enable auto-renew (subscription continues)
await api.post('/api/setSubsSettings', {
  subsSettings: [{
    subId: 'sub_xxx',
    cancelAtPeriodEnd: false
  }]
});

Server Behavior:

  • TRIALING subscriptions: Direct update to cancel_at_period_end only
    • No schedule creation/modification during trial period
    • Trial must complete naturally before schedule enforcement
    • Creating schedules on trial subs would immediately activate and charge customers
  • ACTIVE subscriptions with promo:
    • If enabling auto-renew and promo has future validUntil, creates 2-phase schedule to enforce coupon expiry
    • If disabling auto-renew, releases schedule and sets cancel_at_period_end: true
  • Regular subscriptions: Direct update to cancel_at_period_end on the subscription

Lifecycle Diagram

flowchart TD
    A[New Subscription with Promo] --> B[Schedule Created]
    B --> C[Schedule Released Immediately]
    C --> D[cancel_at_period_end: true]
    D --> E{User Action?}
    E -->|Enable Auto-Renew| F{Status?}
    F -->|TRIALING| G[Update cancel_at_period_end only]
    F -->|ACTIVE| H[cancel_at_period_end: false]
    H --> H2[New Schedule Created]
    H2 --> H3[Coupon expires at validUntil]
    H3 --> I[Normal billing after promo]
    E -->|Keep Default| J[Subscription Cancels at Period End]
    G --> K[Trial continues normally]
    
    style D fill:#FFB6C1
    style F fill:#90EE90
    style J fill:#FFB6C1
    style I fill:#90EE90

Important: When a user enables auto-renew on a promo subscription (changes cancel_at_period_end from true to false), a new 2-phase schedule is created to enforce the promo's validUntil date. This ensures the coupon expires at the correct time instead of continuing indefinitely.

Technical Note: Due to Stripe API limitations, schedule recreation uses a two-step process:

  1. Create schedule from existing subscription (from_subscription)
  2. Update schedule with 2-phase configuration (cannot set phases with from_subscription)

UI Considerations

// Angular service method (simplified!)
getSubscriptionStatus(sub: Subscription): 'active' | 'will-cancel' | 'canceled' {
  if (sub.status === 'canceled') return 'canceled';
  return sub.cancel_at_period_end ? 'will-cancel' : 'active';
}

// Template
<span *ngIf="getSubscriptionStatus(sub) === 'will-cancel'" class="badge warning">
  Ends {{ sub.current_period_end * 1000 | date:'mediumDate' }}
</span>

<!-- Show promo badge if subscription has promo -->
<span *ngIf="sub.metadata?.promoId" class="badge info">
  Promotional Pricing Applied
</span>

<button (click)="toggleAutoRenew(sub)">
  {{ sub.cancel_at_period_end ? 'Enable Auto-Renew' : 'Cancel at Period End' }}
</button>

Admin Management

Adding a Promo via API

# Get admin token (login first)
TOKEN="your-admin-jwt-token"

# Add addon promo
curl -X POST https://your-server/api/admin/subscriptionPromos/add \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "type": "addon",
    "priceKey": "addon_1",
    "enabled": true,
    "validUntil": "2026-04-30T00:00:00.000Z",
    "couponId": "FREE_ADDON_100",
    "name": "Addon Free Until April 2026"
  }'

Promo Rule Fields

Field Type Required Description
_id ObjectId Auto MongoDB generated ID (used in subscription metadata)
type 'package' | 'addon' No Subscription type to match. Null = any type
priceKey string No Price lookup key (e.g., addon_1, ess_1). Null = any price
enabled boolean Yes Whether promo is active
validUntil Date Yes End date for promotional period
couponId string Yes Stripe coupon ID to apply
name string Yes Fallback display name (if translation missing)
nameKey string No i18n key for name (SCREAMING_SNAKE)
descriptionKey string No i18n key for description (SCREAMING_SNAKE)
discountType 'free' | 'percent' | 'fixed' No Type of discount
discountValue number No 100 for free, 50 for 50% off, etc.
usageCount number Auto Number of subscriptions using this promo (prevents deletion)
createdAt Date Auto Creation timestamp

Subscription Metadata

When a promo is applied, promoId and scheduleId are stored in both Stripe and local MongoDB:

Stripe Subscription Metadata

{
  "type": "addon",
  "promoId": "6829a1b2c3d4e5f6a7b8c9d0",
  "scheduleId": "sub_sched_1ABC..."  // Cleared when schedule released
}

Local MongoDB Subscription Schema

// In customer.membership.subscriptions array
{
  subscriptionId: "sub_1ABC...",
  promoId: "6829a1b2c3d4e5f6a7b8c9d0",
  scheduleId: "sub_sched_1ABC..."  // null when schedule released
}

Why only promoId (not names)?

  • Stripe metadata is immutable after creation
  • Storing display names (promoName, promoKey) would become stale if promo rules are updated
  • Use findPromoById() to look up current promo details

Why store in both Stripe and MongoDB?

  • Stripe: Required for Stripe API queries and webhook processing
  • MongoDB: Enables fast local queries like "find all subscriptions using this promo"
  • scheduleId cleared when schedule released to prevent stale references

Looking Up Promo from Subscription

// Server-side: Look up promo details from subscription metadata
const subscription = await stripe.subscriptions.retrieve(subId);
const promoId = subscription.metadata?.promoId;

if (promoId) {
  const promo = await findPromoById(promoId);
  
  if (promo.deleted) {
    // Promo was deleted after subscription was created
    // promo.nameKey = 'PROMO_DELETED'
    console.log('Original promo no longer exists');
  } else {
    // Promo still exists - use current values
    console.log(`Applied promo: ${promo.name} (${promo.nameKey})`);
  }
}

Server-Side Helper Functions

Located in controllers/subscription.js:

Function Purpose
findPromoById(promoId) Look up promo by MongoDB _id, returns { deleted: true } if not found
findMatchingPromo(type, priceKey) Find best matching enabled promo with priority matching
updatePromoSubscriptionSchedules(promoId, newValidUntil) Update all SubscriptionSchedules using a promo to new end date

updatePromoSubscriptionSchedules

This function is called when a promo's validUntil date changes to update affected Stripe SubscriptionSchedules:

/**
 * Update all Stripe SubscriptionSchedules that use a specific promo
 * to end the coupon phase at the new validUntil date.
 * 
 * NOTE: Only updates ACTIVE schedules. Released schedules are skipped
 * because those subscriptions have direct user control.
 * 
 * @param {string} promoId - MongoDB ObjectId of the promo
 * @param {Date} newValidUntil - New date when coupon should be removed
 * @returns {Object} { updated: number, failed: number, errors: string[], skipped: number }
 */
async function updatePromoSubscriptionSchedules(promoId, newValidUntil) {
  // 1. Find all subscriptions with this promoId in metadata
  // 2. For each subscription:
  //    - Skip if schedule is null or status !== 'active' (was released)
  //    - For active schedules: Update phase end_date to newValidUntil
  // 3. Return summary of updates
}

Usage:

// When updating a promo's validUntil date
const results = await updatePromoSubscriptionSchedules(promoId, new Date('2026-04-30'));
// results: { updated: 5, failed: 0, errors: [], skipped: 10 }
// (skipped = subscriptions with released schedules)

Why Skip Released Schedules?

  • Released schedules mean user chose cancel_at_period_end: true (default)
  • Those subscriptions will cancel anyway, no need to update coupon end date
  • Only subscriptions with active schedules (user enabled auto-renew) need coupon date updates

Promo Matching Priority

When creating a subscription, promos are matched in this priority:

  1. Exact match: type AND priceKey both match
  2. Type-only match: type matches, priceKey is null
  3. Catch-all: Both type and priceKey are null
flowchart TD
    A[New Subscription<br/>type: addon, priceKey: addon_1] --> B{Exact match?<br/>type=addon AND priceKey=addon_1}
    B -->|Found| C[Apply promo]
    B -->|Not found| D{Type-only match?<br/>type=addon AND priceKey=null}
    D -->|Found| C
    D -->|Not found| E{Catch-all?<br/>type=null AND priceKey=null}
    E -->|Found| C
    E -->|Not found| F[No promo applied<br/>Normal billing]
    
    style C fill:#90EE90
    style F fill:#FFB6C1

CLI Scripts

Script Workflow Overview

sequenceDiagram
    participant Admin
    participant PauseScript as pause_addon_subs.js
    participant ResumeScript as resume_addon_subs.js
    participant Stripe
    participant Sub as Subscription

    Note over Admin,Sub: PAUSE FLOW
    Admin->>PauseScript: --resume-date 2026-04-30
    PauseScript->>Stripe: List active subs (metadata.type=addon)
    Stripe-->>PauseScript: Addon subscriptions
    loop Each subscription
        PauseScript->>Stripe: Update pause_collection
        Stripe->>Sub: Set resumes_at, behavior=void
    end
    PauseScript-->>Admin: Summary report

    Note over Admin,Sub: AUTO-RESUME (Stripe handles)
    Stripe->>Sub: Resume on 2026-04-30
    Sub->>Stripe: Start billing again

    Note over Admin,Sub: MANUAL RESUME (if needed before date)
    Admin->>ResumeScript: --include-scheduled
    ResumeScript->>Stripe: List paused addon subs
    Stripe-->>ResumeScript: Paused subscriptions
    loop Each subscription
        ResumeScript->>Stripe: Remove pause_collection
        Stripe->>Sub: Resume immediately
    end
    ResumeScript-->>Admin: Summary report

Pause Addon Subscriptions

Pauses all active/trialing addon subscriptions with optional auto-resume date.

cd /path/to/server

# Dry run (preview changes)
node scripts/pause_addon_subs.js --env ./environment_prod.env --resume-date 2026-04-30 --dry-run

# Execute for real
node scripts/pause_addon_subs.js --env ./environment_prod.env --resume-date 2026-04-30

# With custom reason
node scripts/pause_addon_subs.js --env ./environment_prod.env --resume-date 2026-04-30 --reason "addon_launch_promo"

Options:

Option Description Default
--env <path> Path to environment file ./environment.env
--resume-date <date> ISO date for auto-resume None (manual resume)
--reason <string> Reason stored in metadata addon_promo
--dry-run Preview without changes false
--limit <number> Max subscriptions 500

Resume Addon Subscriptions

Resumes paused addon subscriptions manually (before scheduled date).

# Dry run
node scripts/resume_addon_subs.js --env ./environment_prod.env --dry-run

# Execute
node scripts/resume_addon_subs.js --env ./environment_prod.env

# Include subscriptions with scheduled resume dates
node scripts/resume_addon_subs.js --env ./environment_prod.env --include-scheduled

# Filter by pause reason
node scripts/resume_addon_subs.js --env ./environment_prod.env --reason "addon_promo"

Testing Guide

Testing Flow Overview

flowchart TD
    subgraph setup["1. Setup"]
        A[Create 100% coupon in Stripe] --> B[Start server]
    end
    
    subgraph test_api["2. Test API"]
        C[GET /api/activePromos] -->|Empty| D[Add promo via admin API]
        D --> E[GET /api/activePromos]
        E -->|Has promo| F[✓ API working]
    end
    
    subgraph test_new["3. Test New Subscriptions"]
        G[Create new addon subscription] --> H{Coupon applied?}
        H -->|Yes| I[✓ New sub promo working]
        H -->|No| J[Check promo rules & couponId]
    end
    
    subgraph test_existing["4. Test Existing Subscriptions"]
        K[Run pause script --dry-run] --> L{Subs found?}
        L -->|Yes| M[Run pause script for real]
        L -->|No| N[Check metadata.type]
        M --> O[Verify in Stripe Dashboard]
        O --> P[Run resume script]
    end
    
    setup --> test_api
    test_api --> test_new
    test_api --> test_existing

1. Prerequisites

  1. Create 100% coupon in Stripe Dashboard:

    Dashboard → Billing → Coupons → + New
    - ID: FREE_ADDON_100
    - Percent off: 100%
    - Duration: Forever
    
  2. Start the server:

    cd /path/to/server
    node server.js
    

2. Test Public API

# Should return empty array initially
curl -X GET https://localhost:4100/api/activePromos -k
# Expected: []

3. Add a Promo Rule

# Login as admin and get token first, then:
curl -X POST https://localhost:4100/api/admin/subscriptionPromos/add \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <TOKEN>" \
  -d '{
    "type": "addon",
    "priceKey": "addon_1",
    "enabled": true,
    "validUntil": "2026-04-30T00:00:00.000Z",
    "couponId": "FREE_ADDON_100",
    "name": "Addon Free Until April 2026"
  }' -k

4. Verify Public API Returns Promo

curl -X GET https://localhost:4100/api/activePromos -k
# Expected: [{"type":"addon","priceKey":"addon_1","validUntil":"2026-04-30...","name":"..."}]

5. Test Edge Cases via MongoDB

mongosh
use agmission

# Test: Disable promo
db.settings.updateOne({ userId: null }, { $set: { "subscriptionPromos.0.enabled": false } })
# → Public API should return []

# Test: Set expired date
db.settings.updateOne({ userId: null }, { $set: { "subscriptionPromos.0.validUntil": new Date("2024-01-01") } })
# → Public API should return []

# Test: Type mismatch
db.settings.updateOne({ userId: null }, { $set: { "subscriptionPromos.0.type": "package" } })
# → Addon subscriptions should NOT get coupon

# Reset to working state
db.settings.updateOne({ userId: null }, { $set: {
  "subscriptionPromos.0.enabled": true,
  "subscriptionPromos.0.validUntil": new Date("2026-04-30"),
  "subscriptionPromos.0.type": "addon",
  "subscriptionPromos.0.priceKey": "addon_1"
}})

6. Test Pause/Resume Scripts

# Test mode (dry run)
node scripts/pause_addon_subs.js --env ./environment.env --resume-date 2026-04-30 --dry-run

# Production (dry run first!)
node scripts/pause_addon_subs.js --env ./environment_prod.env --resume-date 2026-04-30 --dry-run

# Resume test
node scripts/resume_addon_subs.js --env ./environment.env --dry-run --include-scheduled

7. Verify in Stripe Dashboard

After pause:

  • Go to Subscriptions → find the addon subscription
  • Check pause_collection is set
  • Check metadata has: pauseReason, pausedAt, scheduledResumeAt

After new subscription with promo:

  • Check coupon is applied (100% off or configured discount)
  • Check metadata has: promoId (references promo rule by MongoDB _id)

Stripe Dashboard Setup

Creating Coupons for Promos

Important: Our promo system uses SubscriptionSchedules to control when billing resumes, NOT Stripe's coupon duration. Always create coupons with Duration: Forever.

Step-by-Step Coupon Creation

  1. Go to Stripe DashboardBillingCoupons
  2. Click + New
  3. Configure:
    • Name: Free Addon Promo (descriptive name)
    • ID: ADDON1_FREE_APR2026 (use this in couponId field)
    • Type: Percentage discount
    • Percent off: 100 (for free promos)
    • Duration: ForeverAlways use Forever
    • Apply to: Leave blank (all subscriptions) or select specific products
  4. Click Create coupon

Why Use Forever Duration?

Approach How Billing Resumes Pros Cons
SubscriptionSchedules (our approach) Server creates schedule with phases Precise control, updates when validUntil changes Requires server logic
Coupon Duration Stripe auto-removes after N months Simple Can't update existing subs, timing imprecise

Our system uses SubscriptionSchedules because:

  • All subscriptions using a promo transition at the same validUntil date
  • Admin can update validUntil and existing schedules update automatically
  • Precise date control (vs. "4 months from subscription start")
  • Coupon applies at creation AND renewals while current date < validUntil

Timeline Example (promo validUntil: April 30, 2026, subscription created March 1):

March 1   → Subscription created     → Coupon applied ($0) ✅ current date < validUntil
April 1   → Billing cycle renewal    → Coupon applied ($0) ✅ billing date < validUntil
May 1     → Billing cycle renewal    → Normal price       ❌ billing date > validUntil
June 1    → Billing cycle renewal    → Normal price       (no discount)

Important: If this subscription was created April 25 instead:

April 25  → Subscription created     → Coupon applied ($0) ✅ current date < validUntil
May 25    → First billing renewal    → Normal price       ❌ billing date > validUntil

Only ONE discounted billing (at creation) because first renewal is after validUntil!

sequenceDiagram
    participant Admin
    participant Server
    participant Stripe
    
    Note over Admin,Stripe: 1. Admin Creates Promo
    Admin->>Server: POST /admin/subscriptionPromos/add
    Note right of Server: couponId: ADDON1_FREE_APR2026<br/>validUntil: 2026-04-30
    
    Note over Admin,Stripe: 2. User Subscribes
    Server->>Stripe: Create SubscriptionSchedule
    Note right of Stripe: Phase 1: Coupon until validUntil (Apr 30)<br/>Phase 2: No coupon (normal price)
    
    Note over Admin,Stripe: 3. Admin Extends Promo
    Admin->>Server: PUT /admin/subscriptionPromos/:id
    Note right of Server: validUntil: 2026-06-30
    Server->>Stripe: Update all schedules
    Note right of Stripe: Phase 1 now ends Jun 30

Coupon Naming Convention

{PRODUCT}_{DISCOUNT}_{EXPIRY}

Examples:
- ADDON1_FREE_APR2026     → 100% off addon_1 until April 2026
- ESS_50OFF_DEC2025       → 50% off essential packages until Dec 2025
- ALL_FREE_TRIAL          → 100% off everything (general trial)

Webhook Events

Ensure these events are enabled in your Stripe webhook configuration:

Event When Fired Our Handler
subscription_schedule.completed Schedule's last phase ends Send promo-expired email
subscription_schedule.released Schedule releases subscription Log transition
subscription_schedule.canceled Schedule canceled Log
subscription_schedule.updated Schedule modified Log

Webhook endpoint: /stPmtWH_EP (or your configured path)


Environment Variables

Required in your environment file:

# Stripe keys
STRIPE_SEC_KEY=sk_live_... or sk_test_...
STRIPE_API_VERSION=2025-01-27.acacia

# Price IDs (for reference)
ADDON_1=price_xxx
ESS_1=price_xxx
# ... etc

Troubleshooting

Troubleshooting Decision Tree

flowchart TD
    A[Issue] --> B{What's the problem?}
    
    B -->|Promo not applying| C{Check promo enabled?}
    C -->|No| C1[Enable promo in admin API]
    C -->|Yes| D{validUntil in future?}
    D -->|No| D1[Update validUntil date]
    D -->|Yes| E{type/priceKey match?}
    E -->|No| E1[Fix promo rule matching]
    E -->|Yes| F{Coupon exists in Stripe?}
    F -->|No| F1[Create coupon in Dashboard]
    F -->|Yes| G[Check server logs]
    
    B -->|Pause script fails| H{Using correct env file?}
    H -->|No| H1[Use --env with correct path]
    H -->|Yes| I{Subs have metadata.type?}
    I -->|No| I1[Add metadata to subscriptions]
    I -->|Yes| J[Check Stripe API key mode]
    
    B -->|Public API empty| K{Promo in DB?}
    K -->|No| K1[Add promo via admin API]
    K -->|Yes| L{enabled: true?}
    L -->|No| L1[Enable the promo]
    L -->|Yes| M{validUntil future?}
    M -->|No| M1[Update validUntil]
    M -->|Yes| N[Check server connection]

Promo not applying to new subscriptions

  1. Check promo is enabled: GET /api/admin/subscriptionPromos
  2. Check validUntil is in the future
  3. Check type and priceKey match the subscription being created
  4. Check coupon exists in Stripe Dashboard

Pause script not finding subscriptions

  1. Ensure using correct environment file (test vs prod keys)
  2. Check subscriptions have metadata.type = 'addon'
  3. Run with --dry-run first to see what would be affected

Public API returning empty array

  1. Check promo exists in DB: db.settings.findOne({ userId: null })
  2. Check enabled: true
  3. Check validUntil is future date

Summary

Quick Reference Diagram

flowchart LR
    subgraph existing["Existing Subscriptions"]
        E1[pause_addon_subs.js] -->|Stripe API| E2[Paused<br/>No billing]
        E2 -->|Auto or manual| E3[Resumed<br/>Billing continues]
    end
    
    subgraph new["New Subscriptions"]
        N1[Admin adds promo] -->|Settings DB| N2[Promo rule active]
        N2 -->|createSubscription| N3[Coupon auto-applied]
    end
    
    subgraph frontend["Front-End"]
        F1[GET /api/activePromos] --> F2[Show promo banner]
    end
Task Method
Make existing addon subs free node scripts/pause_addon_subs.js --resume-date YYYY-MM-DD
Resume paused subs early node scripts/resume_addon_subs.js --include-scheduled
Add promo for new subs POST /api/admin/subscriptionPromos/add
Update promo (extend/shorten) PUT /api/admin/subscriptionPromos/:id
Show promo in front-end GET /api/activePromos
Manage promos Admin API endpoints