Skip to main content

🛒 Store Notifications

📖 Overview

The Store Notifications module handles all e-commerce related notifications including subscriptions, invoices, renewals, cancellations, and order setup reminders.

Environment Flags:

  • STORE_ENABLED=true - Subscription/invoice notifications
  • ORDERS_REMINDER_ENABLED=true - Order setup reminders

Trigger Types:

  • Change Stream on StoreInvoice collection
  • Change Stream on StoreSubscription collection
  • Cron jobs for onboarding and order status

Notification Types: email, bell (FCM) Location: notifications/services/store/

🏗️ Architecture

System Flow

graph TB
subgraph "Triggers"
INV[StoreInvoice<br/>Change Stream]
SUB[StoreSubscription<br/>Change Stream]
CRON1[Onboarding Cron<br/>Every 30s]
CRON2[Order Status Cron<br/>Every 14 days]
end

subgraph "Processing"
PROC[processNotifications]
SEND[send onboarding]
REMIND[sendReminder]
STATUS[orderSetupStatus]
end

subgraph "Notifications"
QUEUE[NotificationQueue]
end

INV --> PROC
SUB --> PROC
CRON1 --> SEND
CRON2 --> REMIND
CRON2 --> STATUS

PROC --> QUEUE
SEND --> QUEUE
REMIND --> QUEUE
STATUS --> QUEUE

⚙️ Configuration

Environment Variables

# Module flags
STORE_ENABLED=true # Enable store notifications
ORDERS_REMINDER_ENABLED=true # Enable order reminders

# External dependencies (inherited)
SENDGRID_API_KEY=your_key
REDIS_HOST=localhost
REDIS_PORT=6379

Cron Schedules

// Onboarding emails (every 30 seconds)
cron.schedule('*/30 * * * * *', async () => {
await send(); // Send pending onboarding emails
});

// Onboarding reminders (daily at 2 PM)
cron.schedule('0 14 * * *', async () => {
await sendReminder(); // Send 14-day reminders
});

// Order setup status (handled by ORDERS_REMINDER_ENABLED)
// Runs continuously checking order status

🔄 Change Stream Configuration

StoreInvoice Change Stream

Monitors invoice collection for payment-related events:

const storeInvoiceStream = StoreInvoice.watch(
[
{
$match: {
$or: [
{
// Past due invoice
operationType: 'update',
'updateDescription.updatedFields.charge': { $exists: true },
'fullDocument.status': 'open',
'fullDocument.billing_reason': 'subscription_cycle',
},
{
// Subscription renewed
operationType: 'update',
'updateDescription.updatedFields.status': 'paid',
'fullDocument.billing_reason': 'subscription_cycle',
},
],
},
},
{
$addFields: {
'metadata.past_due_invoice': {
$cond: [
{
$and: [
{ $eq: ['$operationType', 'update'] },
{ $gt: ['$updateDescription.updatedFields.charge', null] },
{ $eq: ['$fullDocument.status', 'open'] },
{ $eq: ['$fullDocument.billing_reason', 'subscription_cycle'] },
],
},
true,
false,
],
},
'metadata.subscription_renewed': {
$cond: [
{
$and: [
{ $eq: ['$operationType', 'update'] },
{ $eq: ['$updateDescription.updatedFields.status', 'paid'] },
{ $eq: ['$fullDocument.billing_reason', 'subscription_cycle'] },
],
},
true,
false,
],
},
},
},
],
{
fullDocument: 'updateLookup',
startAtOperationTime: RESUMETIME || undefined,
},
);

Match Conditions:

  1. Past Due Invoice: Invoice charged but still open (payment failed)
  2. Subscription Renewed: Invoice status changed to paid (successful renewal)

Metadata Flags:

  • metadata.past_due_invoice - Payment failure notification
  • metadata.subscription_renewed - Successful renewal notification

StoreSubscription Change Stream

Monitors subscription collection for lifecycle events:

const storeSubscriptionStream = StoreSubscription.watch(
[
{
$match: {
$or: [
{
// Cancellation requested
operationType: 'update',
'updateDescription.updatedFields.cancel_at_period_end': true,
},
{
// Cancellation request removed
operationType: 'update',
'updateDescription.updatedFields.cancel_at_period_end': false,
'fullDocument.status': { $ne: 'canceled' },
},
{
// Subscription canceled
operationType: 'update',
'updateDescription.updatedFields.status': 'canceled',
},
{
// New subscription
operationType: 'insert',
'fullDocument.status': 'active',
},
{
// Subscription upgraded/downgraded
operationType: 'update',
'updateDescription.updatedFields.previousPlan.id': { $exists: true },
'updateDescription.updatedFields.previousPlan.product': { $exists: false },
},
],
},
},
{
$addFields: {
'metadata.new_subscription': {
$cond: [
/* New subscription logic */
],
},
'metadata.cancelation_requested': {
$cond: [
/* Cancellation requested logic */
],
},
'metadata.cancelation_request_removed': {
$cond: [
/* Cancellation removed logic */
],
},
'metadata.subscription_canceled': {
$cond: [
/* Subscription canceled logic */
],
},
'metadata.subscription_updgrade_downgrade': {
$cond: [
/* Plan change logic */
],
},
},
},
],
{
fullDocument: 'updateLookup',
startAtOperationTime: RESUMETIME || undefined,
},
);

Match Conditions:

  1. Cancellation Requested: User requested cancellation at period end
  2. Cancellation Removed: User reversed cancellation request
  3. Subscription Canceled: Subscription fully canceled
  4. New Subscription: New subscription created and active
  5. Plan Change: Subscription upgraded or downgraded

Metadata Flags:

  • metadata.new_subscription - Welcome notification
  • metadata.cancelation_requested - Cancellation confirmation
  • metadata.cancelation_request_removed - Reactivation confirmation
  • metadata.subscription_canceled - Final cancellation notification
  • metadata.subscription_updgrade_downgrade - Plan change notification

📧 Notification Templates

1. Past Due Invoice

Trigger: Invoice charged but payment failed
Type: email
Template: SendGrid dynamic template
Recipients: Account owner

Content Variables:

{
invoice_id: "in_1234567890",
amount_due: "$99.00",
due_date: "2025-10-20",
subscription_name: "Pro Plan",
payment_method_last4: "4242",
retry_date: "2025-10-21",
account_name: "Business Name",
support_email: "billing@dashclicks.com"
}

2. Subscription Renewed

Trigger: Invoice paid successfully
Type: email, bell
Template: SendGrid dynamic template
Recipients: Account owner

Content Variables:

{
subscription_name: "Pro Plan",
amount_paid: "$99.00",
period_start: "2025-10-01",
period_end: "2025-11-01",
next_billing_date: "2025-11-01",
invoice_id: "in_1234567890",
receipt_url: "https://stripe.com/receipt/..."
}

3. New Subscription

Trigger: New subscription created
Type: email, bell
Template: Welcome email
Recipients: Account owner

Content Variables:

{
subscription_name: "Pro Plan",
features: ["Feature 1", "Feature 2", "Feature 3"],
amount: "$99.00",
billing_cycle: "monthly",
start_date: "2025-10-01",
first_billing_date: "2025-11-01",
getting_started_url: "https://app.dashclicks.com/getting-started"
}

4. Cancellation Requested

Trigger: User requests cancellation at period end
Type: email
Template: Cancellation confirmation
Recipients: Account owner

Content Variables:

{
subscription_name: "Pro Plan",
cancel_at_period_end: true,
current_period_end: "2025-11-01",
access_until: "2025-11-01",
reactivate_url: "https://app.dashclicks.com/billing",
feedback_url: "https://dashclicks.com/feedback"
}

5. Subscription Canceled

Trigger: Subscription fully canceled
Type: email
Template: Final cancellation notice
Recipients: Account owner

Content Variables:

{
subscription_name: "Pro Plan",
canceled_at: "2025-10-15",
final_access_date: "2025-10-15",
data_retention_period: "30 days",
resubscribe_url: "https://app.dashclicks.com/billing",
export_data_url: "https://app.dashclicks.com/export"
}

6. Plan Upgraded/Downgraded

Trigger: Subscription plan changed
Type: email, bell
Template: Plan change confirmation
Recipients: Account owner

Content Variables:

{
old_plan: "Basic Plan",
new_plan: "Pro Plan",
price_difference: "+$50.00",
prorated_amount: "$25.00",
effective_date: "2025-10-15",
next_billing_amount: "$99.00",
next_billing_date: "2025-11-01",
new_features: ["Advanced Analytics", "Priority Support"]
}

7. Order Setup Reminder (14-day)

Trigger: Cron job - order incomplete after 14 days
Type: email
Template: Setup reminder
Recipients: Account owner

Content Variables:

{
order_id: "ord_1234567890",
product_name: "White Label Website",
days_since_purchase: 14,
setup_url: "https://app.dashclicks.com/orders/123/setup",
support_email: "support@dashclicks.com",
support_phone: "(866) 600-3369"
}

🔍 Processing Logic

Invoice Event Processing

// From services/store/processNotification.js
async function processInvoiceNotifications(data) {
const invoice = data.fullDocument;

// Past due invoice
if (data.metadata.past_due_invoice) {
// Fetch account and owner
const account = await Account.findById(invoice.account_id).populate('business').lean();

const owner = await User.findOne({
account: invoice.account_id,
is_owner: true,
}).lean();

// Create email notification
await NotificationQueue.create({
type: 'email',
origin: 'store',
sender_account: invoice.account_id,
recipient: {
name: owner.name,
email: owner.email,
},
content: {
template_id: 'd-past-due-invoice-template',
additional_data: {
invoice_id: invoice.id,
amount_due: formatCurrency(invoice.amount_due),
due_date: formatDate(invoice.due_date),
subscription_name: invoice.lines.data[0].description,
payment_method_last4: invoice.default_payment_method?.card?.last4,
retry_date: formatDate(invoice.next_payment_attempt),
},
},
check_credits: false, // Internal notification
internal_sender: true,
});
}

// Subscription renewed
if (data.metadata.subscription_renewed) {
// Similar logic for renewal notification
// Include both email and FCM bell notification
}
}

Subscription Event Processing

async function processSubscriptionNotifications(data) {
const subscription = data.fullDocument;

// New subscription
if (data.metadata.new_subscription) {
// Welcome email with getting started info
await NotificationQueue.create({
type: 'email',
origin: 'store',
sender_account: subscription.account_id,
recipient: {
name: owner.name,
email: owner.email,
},
content: {
template_id: 'd-new-subscription-template',
additional_data: {
subscription_name: subscription.plan.nickname,
features: subscription.plan.metadata.features,
amount: formatCurrency(subscription.plan.amount),
billing_cycle: subscription.plan.interval,
start_date: formatDate(subscription.start_date),
},
},
check_credits: false,
internal_sender: true,
});

// Bell notification
await NotificationQueue.create({
type: 'fcm',
origin: 'store',
sender_account: subscription.account_id,
recipient: {
user_id: owner._id,
},
content: {
title: 'New Subscription Active',
body: `Your ${subscription.plan.nickname} subscription is now active!`,
click_action: 'https://app.dashclicks.com/billing',
module: 'store',
type: 'subscription',
data: {
subType: 'bell',
subscription_id: subscription.id,
},
},
});
}

// Other subscription events...
}

Order Setup Reminders

// From services/store/orderSetupStatus.js
async function checkOrderSetupStatus() {
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);

// Find orders created 14 days ago, not completed
const incompleteOrders = await StoreOrder.find({
created_at: {
$gte: fourteenDaysAgo,
$lt: new Date(fourteenDaysAgo.getTime() + 24 * 60 * 60 * 1000),
},
status: { $ne: 'completed' },
'metadata.setup_reminder_sent': { $ne: true },
}).lean();

for (const order of incompleteOrders) {
// Send reminder email
await NotificationQueue.create({
type: 'email',
origin: 'store',
sender_account: order.account_id,
recipient: {
name: order.customer_name,
email: order.customer_email,
},
content: {
template_id: 'd-order-setup-reminder-template',
additional_data: {
order_id: order.id,
product_name: order.product_name,
days_since_purchase: 14,
setup_url: `https://app.dashclicks.com/orders/${order.id}/setup`,
},
},
check_credits: false,
internal_sender: true,
});

// Mark reminder as sent
await StoreOrder.updateOne({ _id: order._id }, { 'metadata.setup_reminder_sent': true });
}
}

🚨 Error Handling

Change Stream Reconnection

// Handle disconnected event (Mongoose 6.x)
mongoose.connection.on('disconnected', () => {
logger.log({
initiator: 'notifications/store/change-stream',
message: 'Primary MongoDB connection lost - waiting for automatic reconnection',
});
});

// Monitor reconnection
mongoose.connection.on('reconnected', () => {
logger.log({
initiator: 'notifications/store/change-stream',
message: 'Reconnected to MongoDB',
});
});

// Exit on unrecoverable errors
mongoose.connection.on('error', err => {
logger.error({
initiator: 'notifications/store/change-stream',
message: 'MongoDB connection error',
error: err,
});

// Exit only if critical error
if (err.name === 'MongoNetworkError' && err.message.includes('no primary found')) {
process.exit(1);
}
});

Resume Token Management

// Read resume time from file (survives restarts)
let RESUMETIME = Buffer.from(fs.readFileSync(__dirname + '/../../.starttime')).toString('utf-8');

if (RESUMETIME) {
RESUMETIME = parseInt(RESUMETIME);
RESUMETIME = new (require('mongodb').Timestamp)({ t: RESUMETIME, i: 1 });

logger.log({
initiator: 'notifications/store/change-stream',
message: `RESUMETIME: ${RESUMETIME}`,
});
}

// Use in change stream
StoreInvoice.watch(pipeline, {
fullDocument: 'updateLookup',
startAtOperationTime: RESUMETIME || undefined,
});

💡 Examples

Example 1: Subscription Renewal

Trigger Event:

// StoreInvoice update
{
operationType: 'update',
fullDocument: {
_id: ObjectId("507f1f77bcf86cd799439011"),
id: "in_1234567890",
account_id: ObjectId("507f1f77bcf86cd799439012"),
status: "paid", // Changed from "open"
billing_reason: "subscription_cycle",
amount_paid: 9900, // $99.00
currency: "usd",
subscription: "sub_1234567890",
period_start: 1696118400,
period_end: 1698796800
},
updateDescription: {
updatedFields: {
status: "paid" // Key change
}
}
}

Resulting Notifications:

  1. Email - Renewal receipt
  2. Bell - "Subscription renewed successfully"

Delivery:

  • Sent to: Account owner
  • Using: SendGrid template d-subscription-renewed
  • Result: Email delivered, bell notification shown

Example 2: Payment Failure

Trigger Event:

// StoreInvoice update
{
operationType: 'update',
fullDocument: {
_id: ObjectId("507f1f77bcf86cd799439011"),
id: "in_1234567890",
account_id: ObjectId("507f1f77bcf86cd799439012"),
status: "open", // Still open
billing_reason: "subscription_cycle",
amount_due: 9900,
charge: "ch_1234567890", // Charge attempted
next_payment_attempt: 1696291200 // Retry scheduled
},
updateDescription: {
updatedFields: {
charge: "ch_1234567890" // New charge attempt
}
}
}

Resulting Notification:

Email - Payment failure alert with retry information

Delivery:

  • Sent to: Account owner
  • Template: d-past-due-invoice
  • Content: Invoice details, retry date, payment method update link

Example 3: Subscription Upgrade

Trigger Event:

// StoreSubscription update
{
operationType: 'update',
fullDocument: {
_id: ObjectId("507f1f77bcf86cd799439011"),
id: "sub_1234567890",
account_id: ObjectId("507f1f77bcf86cd799439012"),
status: "active",
plan: {
id: "price_pro_plan",
nickname: "Pro Plan",
amount: 9900
}
},
updateDescription: {
updatedFields: {
previousPlan: {
id: "price_basic_plan" // Previous plan
}
}
}
}

Resulting Notifications:

  1. Email - Plan upgrade confirmation
  2. Bell - "Plan upgraded to Pro"

Content:

  • Old plan: Basic Plan ($49/month)
  • New plan: Pro Plan ($99/month)
  • Prorated charge: $25.00
  • New features unlocked

🐛 Troubleshooting

Issue: Change Stream Not Detecting Events

Symptoms: Subscription events occur but notifications not sent

Check:

  1. Environment flag:

    echo $STORE_ENABLED
    # Should output: true
  2. Change stream active:

    grep "Subscription Event\|Invoice Event" notifications.log
    # Should show event counts
  3. MongoDB replica set:

    # Change streams require replica set
    mongo --eval "rs.status()"

Issue: Duplicate Notifications

Symptoms: Same notification sent multiple times

Causes:

  1. Resume token not working: Service restarting processes old events
  2. Multiple service instances: Same event processed by multiple instances

Solutions:

  • Verify .starttime file exists and is writable
  • Ensure only one instance of notification service running
  • Check for service restart loops in logs

Issue: Missing Notifications

Symptoms: Some events not triggering notifications

Check Match Conditions:

// Verify document matches change stream conditions
db.getCollection('store.invoices').findOne({
_id: ObjectId('missing_invoice_id'),
});

// Check:
// - billing_reason === 'subscription_cycle'
// - status === 'open' or 'paid'
// - charge field exists (for past due)

📈 Metrics

Key Metrics:

  • Invoice notifications per day: ~1000
  • Subscription notifications per day: ~500
  • Order reminders per day: ~50
  • Success rate: >99%
  • Average delivery time: less than 10 seconds

Monitoring:

// Check notification counts
db.getCollection('notifications.queue').aggregate([
{
$match: {
origin: 'store',
created_at: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
},
{
$group: {
_id: '$content.template_id',
count: { $sum: 1 },
},
},
]);

// Check delivery success rate
db.getCollection('communications').aggregate([
{
$match: {
module: 'store',
created_at: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
},
{
$group: {
_id: '$success',
count: { $sum: 1 },
},
},
]);

Module Type: Change Stream + Cron Job
Environment Flags: STORE_ENABLED, ORDERS_REMINDER_ENABLED
Dependencies: MongoDB (replica set), Stripe integration
Status: Active - high volume module

💬

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