1936 lines
63 KiB
Markdown
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 |
|