# 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)