agmission/Development/server/docs/PROMO_USAGE_COUNT_FIX.md

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})`