389 lines
12 KiB
Markdown
389 lines
12 KiB
Markdown
# 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})`
|