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,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​

  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