agmission/Development/server/docs/SUBSCRIPTION_PROMO_INTEGRATION.md

1936 lines
63 KiB
Markdown

# 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**:
```typescript
// 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
```json
{
"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):**
```json
{
"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:
```json
{
"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:
```json
{
"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:**
```json
{
"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):**
```json
{
"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:
```json
{
"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):**
```json
{
"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
```typescript
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:
```javascript
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**:
```json
{
"promos": [...],
"currentMode": {
"mode": "enabled",
"description": "Promotions enabled (targeting controlled by PromoEligibility)",
"isActive": true
}
}
```
See [PROMO_MANAGEMENT.md](./PROMO_MANAGEMENT.md#promotion-mode-global-kill-switch) 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
```mermaid
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
```mermaid
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.
```mermaid
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
```mermaid
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
```mermaid
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:
```typescript
{
"error": {
".tag": "payment_failed",
"message": "Payment failed. Please add a valid payment method."
}
}
```
### Testing Payment Failures
Use Stripe test cards to verify failure handling:
```typescript
// 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
```typescript
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:
- [Payment Failure Handling Documentation](./PAYMENT_FAILURE_HANDLING.md)
- [Promo Management Guide](./PROMO_MANAGEMENT.md)
---
## 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:**
```json
{
"id": "SUMMER50",
"name": "50% OFF Summer Sale",
"percent_off": 50,
"duration": "repeating",
"duration_in_months": 3,
"valid": true
}
```
**Error Response (Customer Not Matching):**
```json
{
"error": {
".tag": "promo_invalid_coupon",
"message": "Coupon \"VIP2026\" is not available for this customer"
}
}
```
**Error Response (Expired Coupon):**
```json
{
"error": {
".tag": "promo_invalid_coupon",
"message": "Coupon expired on 2026-01-31T23:59:59.000Z"
}
}
```
**Error Response (Max Redemptions Reached):**
```json
{
"error": {
".tag": "promo_invalid_coupon",
"message": "Coupon has reached maximum redemption limit"
}
}
```
**Error Response (First-Time Transaction Restricted):**
```json
{
"error": {
".tag": "promo_invalid_coupon",
"message": "Promotion code \"FIRST50\" is restricted to first-time customers only"
}
}
```
**Error Response (Product Not Matching):**
```json
{
"error": {
".tag": "promo_invalid_coupon",
"message": "Coupon \"ENTERPRISE50\" is not applicable to the selected products"
}
}
```
**Error Response (Product Restricted - No Context):**
```json
{
"error": {
".tag": "promo_invalid_coupon",
"message": "Coupon \"PRODUCT50\" is restricted to specific products only"
}
}
```
**Error Response (Invalid):**
```json
{
"error": {
".tag": "promo_invalid_coupon",
"message": "Invalid coupon or promotion code: INVALID123"
}
}
```
**Example Usage:**
```bash
# 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:**
```json
[
{
"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:**
```json
[
{
"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:**
```json
[
{
"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:**
```json
{
"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:**
```json
{
"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):**
```json
{
"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):
```json
{
"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:**
```json
{
"validUntil": "2026-06-30T00:00:00.000Z",
"name": "Extended: Free Addon Until June 2026",
"enabled": true
}
```
**Response:**
```json
{
"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](./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):**
```json
{
"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):**
```json
{
"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.
```mermaid
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
```typescript
// 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
```typescript
// 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)
```html
<!-- 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)
```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
```tsx
// 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
// 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):
```typescript
// 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
```mermaid
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
```typescript
// 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
```bash
# 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
```json
{
"type": "addon",
"promoId": "6829a1b2c3d4e5f6a7b8c9d0",
"scheduleId": "sub_sched_1ABC..." // Cleared when schedule released
}
```
#### Local MongoDB Subscription Schema
```javascript
// 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
```javascript
// 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:
```javascript
/**
* 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:**
```javascript
// 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
```mermaid
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
```mermaid
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.
```bash
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).
```bash
# 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
```mermaid
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:**
```bash
cd /path/to/server
node server.js
```
### 2. Test Public API
```bash
# Should return empty array initially
curl -X GET https://localhost:4100/api/activePromos -k
# Expected: []
```
### 3. Add a Promo Rule
```bash
# 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
```bash
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
```bash
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
```bash
# 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 Dashboard****Billing****Coupons**
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**: `Forever`**Always 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!
```mermaid
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:
```env
# 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
```mermaid
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
```mermaid
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 |