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_tofield 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_COUPONerror 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
- List promotion codes by code with expansion:
expand: ['data.promotion.coupon', 'data.promotion.coupon.applies_to'] - If found, use the expanded coupon data (already includes applies_to)
- Store promotion code object for customer validation
Step 2: Try as Direct Coupon ID
- Retrieve coupon by ID with expansion:
expand: ['applies_to'] - If successful, search for active promotion codes using this coupon
- If promotion code found, treat as promotion code (for restriction checking)
- If no promotion code, use direct coupon
Step 3: Validate Restrictions (only if code resolved successfully)
- First-time transaction: Check
promoCode.restrictions.first_time_transaction→ REJECT if true - Customer restriction: Check
promoCode.customer(only available in promotion code object) → Must match customer ID - Product restrictions:
- Check
promoCode.restrictions.applies_to.products(promotion level) first - Fallback to
coupon.applies_to.productsif promotion level not set - Fetch price objects, extract product IDs, validate at least one matches
- Check
- 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
409withpromo_invalid_couponerror (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
.tagvalue
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 promotionname: Promotion display namediscountDisplay: 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_byfor 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)
- Populated from
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
- For schedule-managed: same as
daysRemaining: Days untilexpiresAt(NULL if noexpiresAt)daysUntilDiscountEnds: Days untildiscountEndsAt(NULL for once coupons)isTimeLimited: True if hasexpiresAtORdiscountEndsAt
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 centscurrency: Currency foramountOff(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
discountEndsAtto show when discount stops for this customer expiresAtis only for schedule-managed promos (rare)- For repeating coupons,
discountEndsAtshows the actual last billing cycle - For forever coupons, check if
discountEndsAtis 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:
subscription.discount.coupon- Active coupons (forever, repeating, or once before first billing)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→ Useenabled(customer targeting viaeligibility)new_renew→ Useenabledwith promo-specificeligibilitynone→ Usedisabled
Affects:
- Subscription creation (
/api/subscription/update) - Invoice preview (
/api/subscription/retrieveNextInvoices) - Public promo display (
/api/activePromos)
Frontend Impact:
/api/activePromosreturns{ promos: [], currentMode: {...} }structure- When
PROMO_MODE='disabled':promosarray is empty,currentMode.isActiveis false - Client can check
currentMode.modeto 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:
- At subscription creation - When a new subscription is created and current date < validUntil
- At subscription renewals - The coupon discount applies only if the renewal/billing date occurs before validUntil
- 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_endcontrols 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:
- Package subscription's trial_end - checked first (takes precedence)
- Addon subscription's trial_end - checked if no package trial
- params.trial_end - fallback if trialConfByType not available
Behavior:
- If
trial_end > validUntil: No schedule created, coupon applied directly, trial continues untiltrial_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
activeimmediately - Invoice is created but not finalized
- No payment attempt is made
- Customer has "free" access without valid payment method
The Solution
The system automatically:
- Finalizes draft invoices created by SubscriptionSchedules
- Verifies payment status after finalization
- Cancels subscriptions if payment fails or requires action
- 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: trueandvalidUntilis in the future - Does NOT expose
couponId(sensitive info) nameKeyanddescriptionKeyare i18n keys (SCREAMING_SNAKE) for Angular translation- If
nameKeytranslation is missing, fall back toname
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'(usesCouponDurationconstants) - Excludes coupons with
duration='once'(not supported in v2+) - See
helpers/constants.jsforCouponDurationconstants - 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 withduration='once'(not supported) - Validates for duplicates - checks type+priceKey, couponId, and overlapping validUntil dates
- Uses
CouponDurationandStripeErrorTypesconstants fromhelpers/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,descriptionvalidUntil- If shortened with usage, must be at leastPROMO_MIN_EXPIRY_DAYSin futureenabled- Toggle promo on/offdiscountType,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): RequiresvalidUntilin request body with minimum grace period - Uses Stripe SubscriptionSchedules to remove coupon at
validUntildate
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 newvalidUntilschedulesFailed: Number of schedules that failed to updatescheduleErrors: 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_endonly- 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
- If enabling auto-renew and promo has future
- Regular subscriptions: Direct update to
cancel_at_period_endon 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:
- Create schedule from existing subscription (
from_subscription) - Update schedule with 2-phase configuration (cannot set
phaseswithfrom_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"
scheduleIdcleared 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:
- Exact match:
typeANDpriceKeyboth match - Type-only match:
typematches,priceKeyis null - Catch-all: Both
typeandpriceKeyare 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
-
Create 100% coupon in Stripe Dashboard:
Dashboard → Billing → Coupons → + New - ID: FREE_ADDON_100 - Percent off: 100% - Duration: Forever -
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
- Go to Stripe Dashboard → Billing → Coupons
- Click + New
- Configure:
- Name:
Free Addon Promo(descriptive name) - ID:
ADDON1_FREE_APR2026(use this incouponIdfield) - Type: Percentage discount
- Percent off:
100(for free promos) - Duration:
Forever← Always use Forever - Apply to: Leave blank (all subscriptions) or select specific products
- Name:
- 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
validUntildate - ✅ Admin can update
validUntiland 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
- Check promo is enabled:
GET /api/admin/subscriptionPromos - Check
validUntilis in the future - Check
typeandpriceKeymatch the subscription being created - Check coupon exists in Stripe Dashboard
Pause script not finding subscriptions
- Ensure using correct environment file (test vs prod keys)
- Check subscriptions have
metadata.type = 'addon' - Run with
--dry-runfirst to see what would be affected
Public API returning empty array
- Check promo exists in DB:
db.settings.findOne({ userId: null }) - Check
enabled: true - Check
validUntilis 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 |