# Promo UsageCount Tracking Fix **Date**: January 8, 2026 **Issue**: `subscriptionPromos.usageCount` not updated when subscriptions deleted or promo periods expired **Status**: ✅ Fixed ## Problem Statement The `usageCount` field in `subscriptionPromos` was only incremented when subscriptions were created with a promo, but never decremented when: 1. A subscription using the promo was deleted 2. The promo period expired (schedule completed/released) This led to inaccurate usage tracking, making it impossible to know how many subscriptions are **actively** using each promo. ## Root Cause The original implementation only had increment logic: ```javascript // Original: Only increment on subscription creation await Settings.updateOne( { userId: null, 'subscriptionPromos._id': appliedPromo._id }, { $inc: { 'subscriptionPromos.$.usageCount': 1 } } ); ``` No decrement logic existed for: - Subscription deletion webhooks (`customer.subscription.deleted`) - Schedule completion webhooks (`subscription_schedule.completed`) - Schedule release webhooks (`subscription_schedule.released`) ## Solution ### 1. Created Helper Function Added `decrementPromoUsageCount()` helper function: ```javascript /** * Decrement the usageCount for a promo when a subscription using it is deleted * or when the promo period expires. * * @param {String} promoId - The MongoDB _id of the promo * @param {String} reason - Reason for decrement (for logging) */ async function decrementPromoUsageCount(promoId, reason) { if (!promoId) return; try { const result = await Settings.updateOne( { userId: null, 'subscriptionPromos._id': promoId, 'subscriptionPromos.usageCount': { $gt: 0 } // Only decrement if > 0 }, { $inc: { 'subscriptionPromos.$.usageCount': -1 } } ); if (result.modifiedCount > 0) { debug(`Decremented usageCount for promo ${promoId} (${reason})`); } else { debug(`Did not decrement usageCount for promo ${promoId} - either not found or already at 0 (${reason})`); } } catch (err) { // Non-critical - log but don't fail the operation debug(`Failed to decrement promo usageCount for ${promoId}:`, err.message); } } ``` **Key Features:** - ✅ Only decrements if `usageCount > 0` (prevents negative values) - ✅ Non-critical error handling (logs but doesn't fail operations) - ✅ Detailed logging with reason for debugging - ✅ Safe MongoDB positional update with `$inc: -1` ### 2. Added Decrement Calls #### A. Subscription Deleted Webhook **Location**: `controllers/subscription.js` line ~70 ```javascript case Events.CUST_SUB_DELETED: // Decrement promo usageCount if subscription had a promo applied if (eventData.metadata?.promoId) { await decrementPromoUsageCount(eventData.metadata.promoId, 'subscription deleted'); } dbCustomer = await updateCustSubStatus(eventData, dbCustomer); // ... rest of handler break; ``` **When Triggered:** - User manually cancels subscription - Subscription deleted due to payment failure - Subscription upgraded/downgraded (old subscription deleted) #### B. Schedule Completed Webhook **Location**: `controllers/subscription.js` line ~2419 ```javascript async function handleSubscriptionScheduleCompleted(schedule, applicator, req) { try { const promoId = schedule.metadata?.promoId; if (!promoId) return; // Decrement promo usageCount when promo period expires await decrementPromoUsageCount(promoId, 'schedule completed - promo period expired'); // ... send promo expired email } catch (err) { debug(`Error handling schedule completed event: ${err.message}`); } } ``` **When Triggered:** - All schedule phases completed (promo period ended) - Rare event (most schedules are released, not completed) #### C. Schedule Released Webhook (Non-Immediate) **Location**: `controllers/subscription.js` line ~2497 ```javascript async function handleSubscriptionScheduleReleased(schedule, applicator, req) { try { const promoId = schedule.metadata?.promoId; if (!promoId) return; // Check if immediate release (within 60 seconds of creation) const createdAt = schedule.created; const releasedAt = schedule.released_at; const IMMEDIATE_RELEASE_THRESHOLD_SECONDS = 60; if (createdAt && releasedAt && (releasedAt - createdAt) < IMMEDIATE_RELEASE_THRESHOLD_SECONDS) { debug(`Immediate release - skipping usageCount decrement`); return; } // Decrement promo usageCount when promo period expires (non-immediate release) await decrementPromoUsageCount(promoId, 'schedule released - promo period expired'); // ... send promo expired email } catch (err) { debug(`Error handling schedule released event: ${err.message}`); } } ``` **When Triggered:** - Schedule released after promo period ends - **NOT triggered** for immediate releases (subscription creation with `cancel_at_period_end: true`) **Important:** Immediate releases (within 60 seconds of creation) do NOT decrement usageCount because the subscription is still actively using the promo. ## Usage Count Lifecycle ### Complete Tracking Flow ``` CREATE SUBSCRIPTION WITH PROMO ↓ usageCount +1 ↓ ┌─────────────────────────────────────┐ │ SUBSCRIPTION ACTIVE WITH PROMO │ │ usageCount reflects active usage │ └─────────────────────────────────────┘ ↓ ├─→ USER DELETES SUBSCRIPTION │ ↓ │ usageCount -1 │ ├─→ PROMO PERIOD EXPIRES (schedule completed) │ ↓ │ usageCount -1 │ └─→ PROMO PERIOD EXPIRES (schedule released after >60s) ↓ usageCount -1 ``` ### Edge Cases Handled 1. **Immediate Schedule Release** - Schedule created and released within 60 seconds (subscription creation) - usageCount NOT decremented (subscription still using promo) - Coupon remains on subscription until manually removed or period ends 2. **Subscription Deleted Before Promo Expires** - usageCount decremented on deletion - Schedule events (completed/released) won't decrement again (subscription gone) 3. **UsageCount Already at 0** - MongoDB query condition `usageCount: { $gt: 0 }` prevents negative values - Operation succeeds but no modification (modifiedCount = 0) 4. **Promo Deleted** - Helper function handles gracefully (logs but doesn't fail) - No error thrown if promo not found ## Files Modified ### Code Changes 1. **controllers/subscription.js** - Added `decrementPromoUsageCount()` helper function (line ~1139) - Added decrement call in `CUST_SUB_DELETED` webhook (line ~73) - Added decrement call in `handleSubscriptionScheduleCompleted()` (line ~2419) - Added decrement call in `handleSubscriptionScheduleReleased()` (line ~2497) - Exported `decrementPromoUsageCount` function (line ~3035) ### Documentation Changes 2. **docs/PROMO_MANAGEMENT.md** - Updated `usageCount` field description in schema - Added webhook handlers table with decrement logic - Documented increment/decrement conditions - Added immediate release detection explanation 3. **docs/PROMO_USAGE_COUNT_FIX.md** (this file) - Complete implementation documentation - Usage tracking lifecycle diagrams - Edge cases and testing instructions ### Test Files 4. **tests/test_promo_usage_count.js** - Code inspection tests - Manual testing instructions - Verification script ## Testing ### Automated Tests Run the test script: ```bash node tests/test_promo_usage_count.js ``` **Expected Output:** ``` 🧪 Testing Promo UsageCount Tracking ✅ Connected to database 📊 Test 1: Check initial promo state Promo: Free Aircraft Tracking PromoId: 507f1f77bcf86cd799439011 Initial usageCount: 5 📊 Test 2-6: Verify implementation... ✅ All logic verified ✅ All Tests Passed! ``` ### Manual Testing #### Test 1: Create Subscription with Promo ```bash # 1. Get initial usageCount curl http://localhost:4100/api/activePromos | jq '.[] | select(.name=="Test Promo") | .usageCount' # Output: 5 # 2. Create subscription with promo curl -X POST http://localhost:4100/api/subscription/update \ -H "Content-Type: application/json" \ -H "Authorization: Bearer TOKEN" \ -d '{ "package": "addon_1", "pmId": "pm_xxx" }' # 3. Check usageCount again curl http://localhost:4100/api/activePromos | jq '.[] | select(.name=="Test Promo") | .usageCount' # Expected: 6 (incremented) ``` #### Test 2: Delete Subscription ```bash # 1. Delete subscription (triggers webhook) # In Stripe Dashboard: Cancel subscription # 2. Wait for webhook to process (~1-2 seconds) # 3. Check usageCount curl http://localhost:4100/api/activePromos | jq '.[] | select(.name=="Test Promo") | .usageCount' # Expected: 5 (decremented) ``` #### Test 3: Promo Period Expires ```bash # 1. Create subscription with promo that expires soon # 2. Wait for validUntil date to pass # 3. Stripe triggers subscription_schedule.completed or .released # 4. Check usageCount - should be decremented ``` ### Monitoring Check debug logs for usageCount changes: ```bash DEBUG=agm:subscription* node server.js ``` **Log Examples:** ``` agm:subscription Incremented usageCount for promo 507f1f77bcf86cd799439011 +0ms agm:subscription Decremented usageCount for promo 507f1f77bcf86cd799439011 (subscription deleted) +5s agm:subscription Decremented usageCount for promo 507f1f77bcf86cd799439011 (schedule completed - promo period expired) +10s ``` ## Deployment Checklist - [x] Code implemented with helper function - [x] Decrement logic added to all webhook handlers - [x] Edge cases handled (immediate release, negative values) - [x] Function exported in module.exports - [x] Documentation updated (PROMO_MANAGEMENT.md) - [x] Test script created - [x] Manual testing in development environment - [ ] Verify with Stripe webhook events - [ ] Monitor production logs after deployment - [ ] Update admin dashboard to show usageCount ## Migration Notes ### Existing Promos For promos created before this fix, the `usageCount` may be higher than actual active subscriptions because deleted/expired subscriptions were never decremented. **Options:** 1. **Reset to 0** (if tracking is not critical): ```javascript await Settings.updateOne( { userId: null }, { $set: { 'subscriptionPromos.$[].usageCount': 0 } } ); ``` 2. **Recalculate from Stripe** (accurate but slow): ```javascript // For each promo const subscriptions = await stripe.subscriptions.list({ limit: 100, expand: ['data.metadata'] }); const activeCount = subscriptions.data.filter(sub => sub.status === 'active' && sub.metadata?.promoId === promoId ).length; await Settings.updateOne( { userId: null, 'subscriptionPromos._id': promoId }, { $set: { 'subscriptionPromos.$.usageCount': activeCount } } ); ``` 3. **Leave as-is** (future subscriptions will be accurate): - Existing usageCount represents "total ever created" - New tracking from deployment forward will be accurate - Acceptable if only relative changes matter ## Related Documentation - [PROMO_MANAGEMENT.md](./PROMO_MANAGEMENT.md) - Complete promo system documentation - [SUBSCRIPTION_PROMO_INTEGRATION.md](./SUBSCRIPTION_PROMO_INTEGRATION.md) - Client integration guide - [PAYMENT_FAILURE_HANDLING.md](./PAYMENT_FAILURE_HANDLING.md) - Payment failure handling ## Support For questions or issues: - Check debug logs: `DEBUG=agm:subscription*` - Verify webhook events in Stripe Dashboard - Review test script output: `node tests/test_promo_usage_count.js` - Check MongoDB for usageCount values: `db.settings.find({}, {'subscriptionPromos.usageCount': 1})`