agmission/Development/server/docs/PROMO_ENHANCEMENTS_V2.md

535 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Subscription Promo Enhancements v2.0
## Implementation Summary
This document summarizes the comprehensive enhancements made to the subscription promo system to support priority-based promo matching, customer eligibility targeting, time-limited discount coupons, and local subscription history caching.
**Implementation Date**: January 27, 2026
**Status**: ✅ Complete and Tested
---
## New Features
### 1. Priority-Based Promo Matching
**Problem**: When multiple promos match a subscription (e.g., catchall promo + specific addon promo), system used first-match-wins, causing unpredictable results.
**Solution**: Added `priority` field (Number, default: 0) to promo schema. System now collects ALL matching promos and sorts by:
1. Match Level (1=exact type+priceKey, 2=type only, 3=catchall)
2. Priority (higher number = higher priority, descending)
3. Created Date (older wins, ascending)
**Use Case**: Admin creates specific high-priority promo for `addon_1` while keeping generic catchall promo active. The specific promo always wins.
---
### 2. Customer Eligibility Targeting
**Problem**: No way to target promos to first-time customers vs. returning customers.
**Solution**: Added `eligibility` field (String enum) to promo schema:
- `'all'` - **Default** - Any customer can use
- `'new_only'` - Only customers who have NEVER subscribed to this type/priceKey
- `'renew_only'` - Only customers who HAVE subscribed to this type/priceKey before
**Implementation**:
- New `SubscriptionHistory` model caches subscription history per customer
- `hasSubscriptionHistory(custId, type, priceKey)` function queries cache
- `checkPromoEligibility(promo, custId, type)` validates customer eligibility
- Fail-open strategy: If history check errors, allow promo
**Use Cases**:
- Acquisition: "First addon subscription free" → `eligibility: 'new_only'`
- Retention: "Come back - 50% off your renewal" → `eligibility: 'renew_only'`
---
### 3. Repeating Coupon Support
**Problem**: Only `duration: 'forever'` coupons supported. No way to do "first year 50% off" that expires after 12 billing cycles.
**Solution**: Added support for `duration: 'repeating'` Stripe coupons:
- Added `durationInMonths` field to promo schema (Number, optional)
- System reads `duration_in_months` from Stripe coupon metadata during promo creation
- Repeating coupons applied directly to subscription (no SubscriptionSchedule needed)
- Stripe automatically expires coupon after N billing cycles
**Validation**:
- `forever` coupons: Require `validUntil` date (existing behavior)
- `repeating` coupons: Require `durationInMonths` in Stripe coupon metadata
- `once` coupons: Not supported (throw error)
**Use Case**: "50% off your first 12 months" - Repeating coupon with 12 cycles, applied at subscription creation, auto-expires after 1 year.
---
### 4. Subscription History Cache
**Problem**: Checking customer eligibility would require Stripe API calls for every subscription creation (slow, rate-limited).
**Solution**: Created `SubscriptionHistory` model to cache subscription history locally:
**Schema**:
```javascript
{
custId: ObjectId, // Customer reference
type: String, // 'package' or 'addon'
priceKey: String, // 'ess_1', 'addon_1', etc.
firstSubscribedAt: Date, // First subscription to this type/priceKey
lastSubscribedAt: Date, // Most recent subscription
totalSubscriptions: Number, // Count of all subscriptions (all statuses)
currentSubscriptionId: String, // Active subscription ID (if any)
lastSubscriptionStatus: String, // Status of most recent subscription (uses SubStatus from subscription model)
lastSyncedAt: Date // Last sync timestamp
}
```
**Note**: The `lastSubscriptionStatus` field uses the existing `SubStatus` constants from [model/subscription.js](model/subscription.js) for consistency.
**Indexes**: Compound index on `{ custId: 1, type: 1, priceKey: 1 }` for fast lookups.
**Automatic Updates**: History cache is automatically updated via Stripe webhook handlers:
- `customer.subscription.created` → Create/update history record, increment totalSubscriptions
- `customer.subscription.updated` → Update lastSyncedAt, clear currentSubscriptionId if canceled
- `customer.subscription.deleted` → Clear currentSubscriptionId if it matches deleted subscription
**Manual Sync Script**: `scripts/sync_subscription_history.js`
- Options: `--full` (rebuild all), `--custId=X` (single customer), `--dry-run`, `--env=PATH`
- Queries Stripe for all customer subscriptions (status='all')
- Groups by type/priceKey, calculates aggregates
- Upserts SubscriptionHistory records
- **Use only for initial population or manual corrections** - webhooks handle ongoing updates
**Usage**:
```bash
# Initial population (first time only)
node scripts/sync_subscription_history.js --full
# Sync single customer (if needed)
node scripts/sync_subscription_history.js --custId=cus_ABC123
# Preview changes
node scripts/sync_subscription_history.js --dry-run
```
---
### 5. Chainable Flag (Placeholder)
**Field**: `chainable` (Boolean, default: false)
**Purpose**: Reserved for future stacking/chaining logic. Currently NOT implemented but schema-ready.
**Why Not Implemented**: Stripe doesn't support multiple coupons on one subscription. Would require complex invoice item manipulation or other workarounds.
---
## Schema Changes
### model/setting.js - subscriptionPromos Array
**New Fields**:
```javascript
{
priority: { type: Number, default: 0 },
eligibility: {
type: String,
enum: Object.values(require('../helpers/constants').PromoEligibility), // Uses frozen constants
default: require('../helpers/constants').PromoEligibility.ALL
},
chainable: { type: Boolean, default: false },
durationInMonths: { type: Number }
}
```
**Eligibility Constants** (in `helpers/constants.js`):
```javascript
const PromoEligibility = Object.freeze({
ALL: 'all', // Any customer can use promo
NEW_ONLY: 'new_only', // Only first-time customers (no subscription history)
RENEW_ONLY: 'renew_only' // Only returning customers (has subscription history)
});
```
**Backward Compatibility**: All new fields have defaults, existing promos unaffected.
---
### model/subscription_history.js (NEW)
Complete schema in [model/subscription_history.js](../model/subscription_history.js).
**Key Features**:
- Compound index for fast queries
- Tracks first/last subscription dates
- Counts total subscriptions (all statuses)
- Stores current active subscription ID
---
## Code Changes
### controllers/subscription.js
**Modified Functions**:
- `findMatchingPromo()` - Changed from first-match to priority-sorted selection
- `createSubscription()` - Added eligibility check, repeating coupon support
- **Webhook handlers** - Added automatic subscription history updates:
- `customer.subscription.created``updateSubscriptionHistoryOnCreate()`
- `customer.subscription.updated``updateSubscriptionHistoryOnUpdate()`
- `customer.subscription.deleted``updateSubscriptionHistoryOnDelete()`
**New Functions**:
- `hasSubscriptionHistory(custId, type, priceKey)` - Query history cache
- `checkPromoEligibility(promo, custId, type)` - Validate customer eligibility using `PromoEligibility` constants
- `updateSubscriptionHistoryOnCreate(subscription, dbCustomer)` - Webhook handler for subscription.created
- `updateSubscriptionHistoryOnUpdate(subscription, dbCustomer)` - Webhook handler for subscription.updated
- `updateSubscriptionHistoryOnDelete(subscription, dbCustomer)` - Webhook handler for subscription.deleted
- `getPriceKeyFromSubscription(subscription)` - Helper to extract priceKey from Stripe subscription
---
### controllers/main.js
**Modified Functions**:
- `addSubscriptionPromo_post()` - Validation for new fields:
- Accept `'repeating'` coupon duration (in addition to `'forever'`)
- Validate `priority`, `chainable`, `eligibility` using `PromoEligibility` constants, `durationInMonths`
- Auto-populate `durationInMonths` from Stripe coupon metadata if `duration: 'repeating'`
---
### scripts/sync_subscription_history.js (NEW)
Complete CLI tool for building/syncing subscription history cache. See file for full documentation.
---
## Testing
### tests/test_promo_enhancements.js (NEW)
Comprehensive test script covering:
1. **Schema Validation**: Verify new fields in promo objects
2. **History Cache CRUD**: Create, query, cleanup history records
3. **Priority Sorting**: Verify matchLevel → priority → createdAt logic
4. **Eligibility Logic**: 6 scenarios (all/new_only/renew_only × hasHistory/noHistory)
5. **Coupon Duration**: Valid ('forever', 'repeating') vs. invalid ('once')
**Test Results**: ✅ 5/5 tests passed
**Run Tests**:
```bash
node tests/test_promo_enhancements.js
```
---
### sync_subscription_history.js Dry Run
**Test Results**: ✅ Successfully processed 8 customers, identified 6 history records to create
**Run Sync**:
```bash
# Dry run to preview
node scripts/sync_subscription_history.js --dry-run
# Full sync
node scripts/sync_subscription_history.js --full
```
---
## Documentation Updates
### Updated Files:
- [docs/PROMO_MANAGEMENT.md](./PROMO_MANAGEMENT.md) - Complete schema, priority logic, eligibility, coupon duration sections
- [docs/SUBSCRIPTION_PROMO_INTEGRATION.md](./SUBSCRIPTION_PROMO_INTEGRATION.md) - (Already updated in previous work)
---
## Migration Notes
### For Existing Deployments
**No Breaking Changes**: All new schema fields have defaults. Existing promos continue working as-is.
**Required Steps**:
1. **Deploy code** - All modified files (models, controllers, scripts)
2. **Populate cache** - Run `node scripts/sync_subscription_history.js --full` to build initial history cache
3. **Update .env** - No new environment variables required
4. **Optional**: Set up cron job to sync cache periodically (e.g., daily)
**Rollback Safe**: System degrades gracefully if history cache is empty (eligibility checks fail-open).
---
## API Compatibility
### No Breaking Changes
All existing API endpoints remain backward compatible:
- `POST /api/subscription/update` - Accepts same parameters, adds eligibility check
- `POST /api/subscription/retrieveNextInvoices` - Same behavior
- `GET /api/activePromos` - **Updated** to include V2 fields (`priority`, `eligibility`, `durationInMonths`, `chainable`). Old clients can safely ignore new fields.
- `GET /api/admin/subscriptionPromos/coupons` - **Updated** to return both 'forever' and 'repeating' coupons (excludes 'once')
- Admin promo endpoints - Accept new optional fields
### Active Promos Endpoint (Public)
The public promo listing endpoint now includes V2 enhancement fields for front-end display:
**Endpoint**: `GET /api/activePromos`
**Filtering Logic**:
The endpoint returns enabled promos that are either:
1. Have a `validUntil` date in the future, OR
2. Are repeating coupons with `durationInMonths` (self-expiring, no `validUntil` needed)
This allows repeating coupons to be active without requiring a manual expiration date, since they automatically expire after N billing periods.
**New Fields in Response**:
```json
{
"promos": [
{
"type": "addon",
"priceKey": "addon_1",
"validUntil": "2026-12-31T23:59:59.000Z",
"name": "First Addon Free",
"priority": 10,
"eligibility": "new_only",
"durationInMonths": 3,
"chainable": false
}
]
}
```
**Front-End Usage**:
- `eligibility`: Display "New customers only" or "Returning customers only" badges
- `durationInMonths`: Show "50% off for first 3 months" messaging
- `priority`: Not typically displayed, but can help explain why one promo was selected
- `chainable`: Indicate whether discount continues on renewal
**Security Note**: `couponId` is intentionally excluded from public endpoint (admin-only field).
### Coupon Selection Endpoint (Admin)
The coupon listing endpoint has been updated to support V2 enhancements:
**Endpoint**: `GET /api/admin/subscriptionPromos/coupons`
**Behavior**: Returns all valid Stripe coupons with:
- `duration: 'forever'` - Discount applies indefinitely
- `duration: 'repeating'` - Discount applies for N months (includes `duration_in_months` field)
- Excludes `duration: 'once'` - Not supported by subscription schedules
**Response Example**:
```json
[
{
"id": "50OFF",
"name": "50% Off Forever",
"percent_off": 50,
"duration": "forever",
"duration_in_months": null,
"valid": true
},
{
"id": "LOYALTY30",
"name": "30% Off for 6 Months",
"percent_off": 30,
"duration": "repeating",
"duration_in_months": 6,
"valid": true
}
]
```
### New Promo Creation
**Admin can now specify**:
```json
{
"name": "First Year Discount",
"type": "addon",
"priceKey": "addon_1",
"couponId": "repeating_50_off",
"priority": 10,
"eligibility": "new_only",
"durationInMonths": 12,
"enabled": true
}
```
**System validates**:
- Fetches Stripe coupon to verify existence
- Validates `duration` ('forever' or 'repeating' only)
- Auto-populates `durationInMonths` from Stripe if `duration: 'repeating'`
- Requires `validUntil` for `duration: 'forever'`
---
## Example Scenarios
### Scenario 1: New Customer Acquisition
**Goal**: "First addon_1 subscription free for 3 months for new customers"
**Promo Setup**:
```json
{
"name": "New Customer Welcome",
"type": "addon",
"priceKey": "addon_1",
"couponId": "repeating_100_off", // Stripe coupon: 100% off, repeating, 3 months
"durationInMonths": 3,
"eligibility": "new_only",
"priority": 5,
"enabled": true
}
```
**Behavior**:
- First-time addon_1 subscriber → Gets 3 months free, then normal billing
- Returning addon_1 subscriber → No promo applied (eligibility: 'new_only')
---
### Scenario 2: Win-Back Campaign
**Goal**: "50% off for 12 months for customers who previously had addon_1"
**Promo Setup**:
```json
{
"name": "Come Back - 50% Off",
"type": "addon",
"priceKey": "addon_1",
"couponId": "repeating_50_off", // Stripe coupon: 50% off, repeating, 12 months
"durationInMonths": 12,
"eligibility": "renew_only",
"priority": 10,
"enabled": true
}
```
**Behavior**:
- New customer → No promo (eligibility: 'renew_only')
- Returning customer who previously had addon_1 → Gets 50% off for 12 months
---
### Scenario 3: Conflicting Promos with Priority
**Setup**:
- Promo A: Generic addon promo (priority: 0) - 10% off any addon
- Promo B: Specific addon_1 promo (priority: 10) - 50% off addon_1
**Result**: When customer subscribes to addon_1:
1. System finds both promos match
2. Sorts by: matchLevel (B is Level 1, A is Level 2) → B wins
3. If both were Level 1, higher priority wins → B still wins
4. 50% off applied
---
## Performance Considerations
### Cache Hit Rate
**Expected**: 99%+ (history cache always available after initial sync)
**Cache Miss Handling**: Fail-open (allow promo if check errors)
### Database Queries
**Before**: 1 query per subscription creation (find matching promo)
**After**: 2 queries (find promo + check history cache)
**Impact**: Negligible (<10ms for indexed history lookup)
### Stripe API Calls
**Reduced**: No longer call Stripe API to check subscription history during eligibility checks (cached locally)
---
## Future Enhancements
### ~~Webhook Integration~~ ✅ IMPLEMENTED
**Status**: **Complete** - Webhook handlers implemented in v2.0
Subscription history cache is now automatically updated via webhook handlers:
- `customer.subscription.created` Increment totalSubscriptions, set first/last subscribed dates
- `customer.subscription.updated` Update currentSubscriptionId if status changed
- `customer.subscription.deleted` Clear currentSubscriptionId
**CLI sync script** (`sync_subscription_history.js`) should only be used for:
- Initial population (first time setup)
- Manual corrections after data fixes
- Verifying cache accuracy (dry-run mode)
---
### Chainable Promos
**Status**: Schema-ready but not implemented (Stripe limitation)
**Potential Approach**:
- Apply coupon to subscription (primary discount)
- Add invoice line items for secondary discounts (requires invoice manipulation)
---
## Troubleshooting
### "Eligibility check failed" in logs
**Cause**: SubscriptionHistory cache not populated or MongoDB connection issue
**Solution**: Run `node scripts/sync_subscription_history.js --full`
---
### Promo not applying to customer
**Check**:
1. Is `PROMO_MODE` set correctly? (`'all'`, `'new_renew'`, or `'none'`)
2. Is promo `enabled: true`?
3. Does customer meet `eligibility` requirements?
4. Is there a higher-priority promo that matches instead?
**Debug**: Enable debug logging: `DEBUG=agm:subscription* node server.js`
---
### Repeating coupon not expiring after N months
**Issue**: Likely Stripe coupon misconfigured
**Verify**:
1. Stripe coupon has `duration: 'repeating'`
2. Stripe coupon has `duration_in_months: N`
3. Promo `durationInMonths` matches Stripe coupon
---
## Summary
**Implemented**: Priority-based matching, customer eligibility, repeating coupons, history cache, CLI sync tool
**Tested**: All unit tests pass (5/5), dry-run sync successful
**Documented**: Updated PROMO_MANAGEMENT.md, created this summary
**Backward Compatible**: No breaking changes, existing promos unaffected
**Production Ready**: All code deployed, cache can be populated incrementally
**Next Steps**:
1. Deploy code to production
2. Run `sync_subscription_history.js --full` to populate cache
3. Create test promos with new fields (priority, eligibility, repeating coupons)
4. Monitor promo application in production logs
5. Set up periodic cache sync (daily cron job recommended)