# 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
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
with coupon applied] G --> G2[Release Schedule Immediately
Set cancel_at_period_end: true] F -->|No| H[Normal billing
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
Phase 2: no coupon (normal billing)
end_behavior: 'release' Stripe-->>Server: Schedule created with subscription Server->>Stripe: stripe.subscriptionSchedules.release() Note right of Stripe: Schedule released immediately
Subscription now standalone
Coupon still applied! Server->>Stripe: stripe.subscriptions.update()
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
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
Priority: Package > Addon > params] C --> D{trial_end > validUntil?} D -->|Yes| E[Skip SubscriptionSchedule
Apply coupon directly
Trial continues normally] D -->|No| F[Create SubscriptionSchedule
Phase 1: coupon until validUntil
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
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 # 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 ``` #### 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
{ 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 { return this.http.get('/api/activePromos'); } getAddonPromo(): Observable { 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
{{ addonPromo.nameKey ? (addonPromo.nameKey | translate) : addonPromo.name }}

100% Free

{{ addonPromo.discountValue }}% Off

{{ addonPromo.discountValue / 100 | currency }} Off

Valid until {{ addonPromo.validUntil | date:'mediumDate' }}

``` ### 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([]); 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
Loading...
; return (
{addonPromo && (

{addonPromo.name}

Valid until {new Date(addonPromo.validUntil).toLocaleDateString()}

)} {/* Rest of subscription UI */}
); } ``` --- ## 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 Ends {{ sub.current_period_end * 1000 | date:'mediumDate' }} Promotional Pricing Applied ``` --- ## 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
type: addon, priceKey: addon_1] --> B{Exact match?
type=addon AND priceKey=addon_1} B -->|Found| C[Apply promo] B -->|Not found| D{Type-only match?
type=addon AND priceKey=null} D -->|Found| C D -->|Not found| E{Catch-all?
type=null AND priceKey=null} E -->|Found| C E -->|Not found| F[No promo applied
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 to environment file | `./environment.env` | | `--resume-date ` | ISO date for auto-resume | None (manual resume) | | `--reason ` | Reason stored in metadata | `addon_promo` | | `--dry-run` | Preview without changes | false | | `--limit ` | 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 " \ -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
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)
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
No billing] E2 -->|Auto or manual| E3[Resumed
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 |