Skip to main content

🏆 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 array
  • thresholds: Ceiling values by tier name
  • tierOrder: 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

  1. Check Existing: Query for existing loyalty pulse
  2. Compare Tier: Check if tier changed
  3. 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,200Tier: SILVER

// Action
Update pulse:
subType: 'BRONZE''SILVER'
metadata.mrr: 4501200
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

  1. Index Optimization
// Required indexes
StoreSubscription: { account: 1, status: 1, canceled_at: 1 }
ProjectsPulse: { account: 1, type: 1, status: 1 }
  1. Filtering Early
  • Filter by minThreshold before grouping
  • Exclude old cancellations in match stage
  1. 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

Processor Type: Aggregation-based
Complexity: HIGH
Lines of Code: 674
Critical: Customer retention strategy

💬

Documentation Assistant

Ask me anything about the docs

Hi! I'm your documentation assistant. Ask me anything about the docs!

I can help you with:
- Code examples
- Configuration details
- Troubleshooting
- Best practices

Try asking: How do I configure the API?
09:31 AM