12 KiB
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:
- A subscription using the promo was deleted
- 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:
// 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:
/**
* 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
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
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
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
-
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
-
Subscription Deleted Before Promo Expires
- usageCount decremented on deletion
- Schedule events (completed/released) won't decrement again (subscription gone)
-
UsageCount Already at 0
- MongoDB query condition
usageCount: { $gt: 0 }prevents negative values - Operation succeeds but no modification (modifiedCount = 0)
- MongoDB query condition
-
Promo Deleted
- Helper function handles gracefully (logs but doesn't fail)
- No error thrown if promo not found
Files Modified
Code Changes
- controllers/subscription.js
- Added
decrementPromoUsageCount()helper function (line ~1139) - Added decrement call in
CUST_SUB_DELETEDwebhook (line ~73) - Added decrement call in
handleSubscriptionScheduleCompleted()(line ~2419) - Added decrement call in
handleSubscriptionScheduleReleased()(line ~2497) - Exported
decrementPromoUsageCountfunction (line ~3035)
- Added
Documentation Changes
-
docs/PROMO_MANAGEMENT.md
- Updated
usageCountfield description in schema - Added webhook handlers table with decrement logic
- Documented increment/decrement conditions
- Added immediate release detection explanation
- Updated
-
docs/PROMO_USAGE_COUNT_FIX.md (this file)
- Complete implementation documentation
- Usage tracking lifecycle diagrams
- Edge cases and testing instructions
Test Files
- tests/test_promo_usage_count.js
- Code inspection tests
- Manual testing instructions
- Verification script
Testing
Automated Tests
Run the test script:
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
# 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
# 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
# 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:
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
- Code implemented with helper function
- Decrement logic added to all webhook handlers
- Edge cases handled (immediate release, negative values)
- Function exported in module.exports
- Documentation updated (PROMO_MANAGEMENT.md)
- Test script created
- 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:
-
Reset to 0 (if tracking is not critical):
await Settings.updateOne( { userId: null }, { $set: { 'subscriptionPromos.$[].usageCount': 0 } } ); -
Recalculate from Stripe (accurate but slow):
// 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 } } ); -
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 - Complete promo system documentation
- SUBSCRIPTION_PROMO_INTEGRATION.md - Client integration guide
- 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})