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→ Useenabled(customer targeting viaeligibilityfield)new_renew→ Useenabledwitheligibility='new_only'or'renew_only'none→ Usedisabled
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 promoeligibility: '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/activePromosreturns 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
- Promo Creation: Admin creates a promo with name (i18n supported), description, coupon ID, and validity period
- 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 tovalidUntilfor legacy promos) - A SubscriptionSchedule is created (to apply the coupon)
- Schedule is immediately released - giving user direct control
cancel_at_period_endis set totrue(default - user must opt-in to auto-renew)- The scheduleId and a snapshot of
discountEndsAtare stored in subscription metadata
- Coupon Expiry: Once
discountEndsAt(orvalidUntilfallback) passes, any renewal on or after that date charges the normal price (no discount) - Billing Cycle Impact: The subscription's billing cycle day determines how many discounted renewals occur before
discountEndsAt - Trial Takes Precedence: If trial_end > validUntil, the subscription is created WITHOUT the promo/schedule
- User Control: User can toggle auto-renew at any time via standard
cancel_at_period_endupdate
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):
- Match Level: More specific matches win
- Level 1 (Exact):
typeANDpriceKeymatch → Most specific - Level 2 (Type):
typematches,priceKeyis null → Medium specific - Level 3 (Catchall): Both
typeandpriceKeyare null → Least specific
- Level 1 (Exact):
- Priority: Higher
priorityvalue wins (within same match level) - 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
SubscriptionHistorycache 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 optionallydiscountEndsAt(absolute expiry for existing subscribers) discountEndsAtis 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(orvalidUntilifdiscountEndsAtnot set) - Phase 2: Normal billing after
discountEndsAt
Repeating Coupons:
- Stripe coupon has
duration: 'repeating'+duration_in_months: 12 - System reads
durationInMonthsfrom coupon metadata during promo creation - Coupon applied directly to subscription (no schedule needed)
- Stripe automatically removes coupon after N billing cycles
discountEndsAtis NOT set on the promo — it's subscriber-specific:subscription.start_date + durationInMonths
Validation:
forevercoupon + novalidUntil→ Errorrepeatingcoupon + nodurationInMonthsin Stripe metadata → Erroroncecoupon → 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 customerseligibility: 'new_only'→ Shows promo pricing only for first-time customerseligibility: '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:
couponparameter accepts both coupon IDs and promotion codes (automatically resolved) - Explicit
couponparameter always takes precedence over automatic promos - Promo matching uses same logic as subscription creation (type + priceKey)
- Supports both
duration: 'forever'and'repeating'coupons (usesCouponDurationconstants)
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 datependingPromoDetails— full promo shape (same aspromoDetailsfromGET /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
promoIdandscheduleIdstored in Stripe subscription metadata for API queries - NEW: Same fields also stored in local MongoDB for fast promo-based subscription queries
scheduleIdis 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
- Schedule is released for
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:
- System finds all active promos using that coupon
- Sets
enabled: falseon each promo - Sets
validUntil: nowto prevent new usage - Logs each disabled promo
- 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 namepromoName- The promo that endedsubType- Subscription product namenewBillingDate- Next billing date (formatted)chargeAmount- Amount to be chargedmanageSubUrl- 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_enddirectly 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
- Only updates
- If subscription is ACTIVE and has
promoIdin metadata and promo'svalidUntilis in the future:- Create a new 2-phase schedule to enforce
validUntil(two-step process):- Create schedule from existing subscription using
from_subscription - Update schedule with custom phases (Stripe API limitation)
- Create schedule from existing subscription using
- Phase 1: Coupon applied until
validUntil - Phase 2: No coupon (normal price)
- Create a new 2-phase schedule to enforce
- If no promo or
validUntilis past: Direct update tocancel_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 forsubscriptionPromos - 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-sendPromoExpiredEmailfunction - 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
- Always create coupons in Stripe first before adding promos
- Set reasonable validUntil dates - at least
PROMO_MIN_EXPIRY_DAYSin the future - Use translation keys for multi-language support (nameKey, descriptionKey)
- Monitor webhook logs for schedule events
- 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:
- Go to Products > Coupons > + New
- Set Percent off: 100%
- Set Duration: Forever (schedule handles expiry)
- Set ID:
ADDON1_FREE_APR2026 - 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
- Create/Login as a test customer
- Subscribe to a package first (e.g., ess_1)
- Add addon_1 subscription
- Verify in Stripe Dashboard:
- Subscription should have
promoIdandscheduleIdin metadata - Coupon should be applied (100% off)
cancel_at_period_endshould betrue(default)- Schedule should be in
releasedstatus
- Subscription should have
- Test Auto-Renew Toggle:
- Call
setSubsSettingswithcancelAtPeriodEnd: false - Verify subscription's
cancel_at_period_endchanges tofalse
- Call
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:
- Create a promo with a shorter
validUntil(e.g., tomorrow) - Use Stripe test clock to advance time
- 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