agmission/Development/server/docs/PROMO_USAGE_COUNT_FIX.md

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:

  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:

// 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

  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

  1. 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
  2. docs/PROMO_USAGE_COUNT_FIX.md (this file)

    • Complete implementation documentation
    • Usage tracking lifecycle diagrams
    • Edge cases and testing instructions

Test Files

  1. 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:

  1. Reset to 0 (if tracking is not critical):

    await Settings.updateOne(
      { userId: null },
      { $set: { 'subscriptionPromos.$[].usageCount': 0 } }
    );
    
  2. 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 } }
    );
    
  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

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