🏆 Loyalty Milestone Processor
🎯 Overview
The Loyalty Milestone processor tracks customer loyalty tiers based on Monthly Recurring Revenue (MRR) with dynamic, configuration-driven thresholds. This 674-line complex processor uses sliding 30-day windows, discount handling, and MongoDB aggregation to calculate accurate loyalty tiers and create celebration pulses when customers reach new milestones.
Source File: queue-manager/services/projects/loyaltyMilestone.js
Execution: Part of projects cron (every 5 minutes)
Complexity: HIGH - 674 lines with complex aggregation logic
Business Impact: CRITICAL - Customer retention and engagement
🔄 Processing Flow
sequenceDiagram
participant CRON as Projects Cron
participant SVC as Loyalty Service
participant CONFIG as Config Collection
participant SUB as Subscriptions
participant PULSE as Projects Pulse
participant LOGGER as Logger
CRON->>SVC: loyaltyMilestone()
SVC->>CONFIG: Fetch loyalty config
CONFIG-->>SVC: Tiers & thresholds
SVC->>SVC: Calculate 30-day window
SVC->>SUB: Aggregate subscriptions<br/>with discount calculations
loop For Each Account
SUB-->>SVC: Account MRR data
SVC->>SVC: Determine tier<br/>(dynamic conditions)
SVC->>PULSE: Check existing pulse
alt Tier Changed or No Pulse
SVC->>PULSE: Create/update pulse
SVC->>LOGGER: Log milestone
end
end
SVC->>LOGGER: Complete
📊 Key Features
1. Dynamic Tier Configuration
Tiers are loaded from database configuration, not hard-coded:
// Example config from MongoDB
{
type: 'loyalty-program',
tiers: [
{ tier: 'bronze', ceiling: 500 },
{ tier: 'silver', ceiling: 1000 },
{ tier: 'gold', ceiling: 5000 },
{ tier: 'platinum', ceiling: Infinity }
]
}
2. Sliding 30-Day Window
Uses proper recurring subscription handling:
- Current time vs 30 days ago
- Includes active, past_due, trialing, canceled (within 30 days)
- Excludes subscriptions canceled >30 days ago
- Handles monthly, yearly, and custom intervals
3. Discount Handling
Complex discount calculation supporting:
- Percentage discounts (
percent_off) - Amount discounts (
amount_off) - Legacy single discount object
- New array-based discounts
- Fallback to zero if no discount
4. MRR Normalization
Converts all billing intervals to monthly:
monthly_cost = plan.amount / plan.interval_count - discounts;
🔧 Core Functions
getLoyaltyConfig()
Fetches and processes loyalty configuration:
async function getLoyaltyConfig() {
const config = await Config.findOne({ type: 'loyalty-program' }).lean();
const tierThresholds = {};
const tierOrder = [];
config.tiers.forEach(tier => {
const tierName = tier.tier.toUpperCase();
if (tier.ceiling !== 'Infinity') {
tierThresholds[tierName] = tier.ceiling;
}
tierOrder.push(tierName);
});
return { tiers: config.tiers, thresholds: tierThresholds, tierOrder };
}
Returns:
tiers: Full tier configuration arraythresholds: Ceiling values by tier nametierOrder: Ordered list for comparison
buildTierCondition()
Builds MongoDB conditional expression for tier determination:
function buildTierCondition(config, mrrField) {
let condition = tiers[tiers.length - 1].tier.toUpperCase(); // Default: highest
// Build from highest to lowest
for (let i = tiers.length - 2; i >= 0; i--) {
const tier = tiers[i];
condition = {
$cond: [{ $lt: [mrrField, tier.ceiling] }, tier.tier.toUpperCase(), condition],
};
}
return condition;
}
Example Output:
{
$cond: [
{ $lt: ['$mrr', 500] },
'BRONZE',
{
$cond: [
{ $lt: ['$mrr', 1000] },
'SILVER',
{
$cond: [{ $lt: ['$mrr', 5000] }, 'GOLD', 'PLATINUM'],
},
],
},
];
}
🗄️ MongoDB Aggregation Pipeline
Stage 1: Match Active Subscriptions
{
$match: {
account: { $exists: true, $ne: null },
'plan.amount': { $exists: true, $gt: 0 },
status: { $in: ['active', 'past_due', 'trialing', 'canceled', 'incomplete', 'unpaid'] },
$or: [
{ canceled_at: null },
{ canceled_at: { $gt: thirtyDaysAgo } }
]
}
}
Stage 2: Calculate Monthly Cost with Discounts
Complex projection handling:
- Base amount normalization
- Percentage discount calculation
- Amount discount calculation
- Array-based discount fallback
- Final monthly cost after discounts
Stage 3: Group by Account
{
$group: {
_id: '$account',
total_mrr: { $sum: '$monthly_cost' },
subscription_count: { $sum: 1 },
oldest_subscription: { $min: '$created' }
}
}
Stage 4: Filter and Determine Tier
{
$match: { total_mrr: { $gte: minThresholdCents } }
},
{
$project: {
account: '$_id',
mrr_dollars: { $divide: ['$total_mrr', 100] },
loyalty_tier: buildTierCondition(config, { $divide: ['$total_mrr', 100] })
}
}
📝 Pulse Creation Logic
Pulse Structure
{
account: accountId,
type: 'loyalty_milestone',
subType: loyaltyTier,
status: 'active',
metadata: {
tier: loyaltyTier,
mrr: mrrDollars,
subscription_count: subscriptionCount,
calculated_at: new Date()
}
}
Update Strategy
- Check Existing: Query for existing loyalty pulse
- Compare Tier: Check if tier changed
- Update or Create:
- If no pulse: Create new
- If tier changed: Update subType and metadata
- If same tier: Skip (no duplicate pulses)
🔧 Configuration
Database Configuration
// MongoDB document in 'configs' collection
{
_id: ObjectId("..."),
type: "loyalty-program",
tiers: [
{ tier: "bronze", ceiling: 500, benefits: [...] },
{ tier: "silver", ceiling: 1000, benefits: [...] },
{ tier: "gold", ceiling: 5000, benefits: [...] },
{ tier: "platinum", ceiling: "Infinity", benefits: [...] }
],
createdAt: ISODate("..."),
updatedAt: ISODate("...")
}
Environment Variables
MONGO_DB_URL=mongodb://localhost:27017/dashclicks
QM_PROJECTS=true
🗄️ Collections Used
Input Collections
store-subscription
- Query: Active subscriptions with amount > 0
- Fields: plan, discount, discounts, status, created, canceled_at
- Indexes: account, status, canceled_at
config
- Query: type = 'loyalty-program'
- Fields: tiers array with tier name and ceiling
- Purpose: Dynamic tier threshold configuration
Output Collection
projects-pulse
- Purpose: Stores loyalty milestone notifications
- Type: 'loyalty_milestone'
- SubType: Tier name (BRONZE, SILVER, GOLD, etc.)
- Unique: One active pulse per account
📊 Business Logic Examples
Example 1: Bronze Tier Customer
// Input
Account: 123
Subscriptions:
- Plan A: $199/month (no discount)
- Plan B: $250/month (20% off) = $200/month
Total MRR: $399
// Processing
$399 < $500 (Bronze ceiling) → Tier: BRONZE
// Output
Pulse: {
account: 123,
type: 'loyalty_milestone',
subType: 'BRONZE',
metadata: { tier: 'BRONZE', mrr: 399 }
}
Example 2: Tier Upgrade
// Previous State
Existing pulse: { subType: 'BRONZE', mrr: 450 }
// New State
New MRR: $1,200 → Tier: SILVER
// Action
Update pulse:
subType: 'BRONZE' → 'SILVER'
metadata.mrr: 450 → 1200
Log: "Account 123 upgraded from BRONZE to SILVER"
Example 3: Discount Handling
// Subscription
plan.amount: 50000 cents ($500)
plan.interval: 'month'
plan.interval_count: 1
discount.coupon.percent_off: 25
// Calculation
baseAmount = 50000 / 1 = 50000 cents
discountPercent = 25
afterDiscount = 50000 - (50000 * 0.25) = 37500 cents = $375/month
🚨 Error Handling
Configuration Errors
if (!config || !config.tiers || config.tiers.length === 0) {
throw new Error('Loyalty program configuration not found');
}
Prevents processing with invalid configuration.
Aggregation Errors
try {
const results = await StoreSubscription.aggregate(query);
// Process results
} catch (error) {
logger.error({
initiator: 'QM/projects/loyaltyMilestone',
message: 'Aggregation failed',
error: error,
});
// Continue to next iteration
}
Pulse Creation Errors
Individual pulse failures don't stop batch processing:
for (const account of accounts) {
try {
await createOrUpdatePulse(account);
} catch (error) {
logger.error({ account, error });
// Continue with next account
}
}
📈 Performance Considerations
Execution Time
- Typical: 5-10 seconds
- Peak: 20-30 seconds (10,000+ subscriptions)
- Bottleneck: MongoDB aggregation
Optimization Strategies
- Index Optimization
// Required indexes
StoreSubscription: { account: 1, status: 1, canceled_at: 1 }
ProjectsPulse: { account: 1, type: 1, status: 1 }
- Filtering Early
- Filter by minThreshold before grouping
- Exclude old cancellations in match stage
- Projection Limiting
- Only project needed fields
- Use lean queries for config
Memory Usage
Large aggregation results cached in memory:
- 10,000 subscriptions ≈ 50-100MB
- Processed in batches if memory constrained
🔗 Related Documentation
- Projects Module Overview
- Projects Pulse Collection
- Store Subscriptions (link removed - file does not exist)
- Configuration Management
Processor Type: Aggregation-based
Complexity: HIGH
Lines of Code: 674
Critical: Customer retention strategy