chore(api): improve billing notifications

This commit is contained in:
isra el
2025-10-29 06:48:03 +03:00
parent e2246cf8e8
commit 552a90685a
3 changed files with 126 additions and 17 deletions

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { InjectQueue } from '@nestjs/bull'
import { Queue } from 'bull'
import { Model, Types } from 'mongoose'
import {
BillingNotification,
@@ -23,7 +24,7 @@ export class BillingNotificationsService {
constructor(
@InjectModel(BillingNotification.name)
private readonly notificationModel: Model<BillingNotificationDocument>,
private readonly eventEmitter: EventEmitter2,
@InjectQueue('billing-notifications') private readonly billingQueue: Queue,
) {}
async notifyOnce({ userId, type, title, message, meta = {}, sendEmail = true }: NotifyOnceInput) {
@@ -34,7 +35,25 @@ export class BillingNotificationsService {
const created = await this.createNotification(userId, type, title, message, meta)
this.emitCreatedEvent(created, { sendEmail })
await this.billingQueue.add(
'send',
{
notificationId: created._id,
userId: created.user,
type: created.type,
title: created.title,
message: created.message,
meta: created.meta,
createdAt: created.createdAt,
sendEmail,
},
{
delay: 30000,
attempts: 3,
removeOnComplete: true,
backoff: { type: 'exponential', delay: 2000 },
},
)
return created
}
@@ -84,18 +103,6 @@ export class BillingNotificationsService {
})
}
private emitCreatedEvent(notification: BillingNotificationDocument, options: { sendEmail: boolean }) {
this.eventEmitter.emit('billing.notification.created', {
notificationId: notification._id,
userId: notification.user,
type: notification.type,
title: notification.title,
message: notification.message,
meta: notification.meta,
createdAt: notification.createdAt,
sendEmail: options.sendEmail,
})
}
}
export { BillingNotificationType }

View File

@@ -16,10 +16,20 @@ import { Device, DeviceSchema } from '../gateway/schemas/device.schema'
import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-session.schema'
import { BillingNotification, BillingNotificationSchema } from './schemas/billing-notification.schema'
import { BillingNotificationsService } from './billing-notifications.service'
import { BillingNotificationsListener } from './billing-notifications.listener'
// import { BillingNotificationsListener } from './billing-notifications.listener'
import { BullModule } from '@nestjs/bull'
import { BillingNotificationsProcessor } from 'src/billing/queue/billing-notifications.processor'
@Module({
imports: [
BullModule.registerQueue({
name: 'billing-notifications',
defaultJobOptions: {
attempts: 2,
removeOnComplete: true,
removeOnFail: false,
},
}),
MongooseModule.forFeature([
{ name: Plan.name, schema: PlanSchema },
{ name: Subscription.name, schema: SubscriptionSchema },
@@ -34,7 +44,7 @@ import { BillingNotificationsListener } from './billing-notifications.listener'
MailModule,
],
controllers: [BillingController],
providers: [BillingService, AbandonedCheckoutService, BillingNotificationsService, BillingNotificationsListener],
providers: [BillingService, AbandonedCheckoutService, BillingNotificationsService, BillingNotificationsProcessor],
exports: [BillingService, AbandonedCheckoutService, BillingNotificationsService],
})
export class BillingModule {}

View File

@@ -0,0 +1,92 @@
import { Process, Processor } from '@nestjs/bull'
import { InjectModel } from '@nestjs/mongoose'
import { Job } from 'bull'
import { Model, Types } from 'mongoose'
import { MailService } from '../../mail/mail.service'
import { User, UserDocument } from '../../users/schemas/user.schema'
import {
BillingNotification,
BillingNotificationDocument,
} from '../schemas/billing-notification.schema'
@Processor('billing-notifications')
export class BillingNotificationsProcessor {
constructor(
private readonly mailService: MailService,
@InjectModel(BillingNotification.name)
private readonly notificationModel: Model<BillingNotificationDocument>,
@InjectModel(User.name)
private readonly userModel: Model<UserDocument>,
) {}
@Process({ name: 'send', concurrency: 1 })
async handleSend(job: Job<{
notificationId: Types.ObjectId
userId: Types.ObjectId
type: string
title: string
message: string
meta: Record<string, any>
createdAt: Date
sendEmail?: boolean
}>) {
const payload = job.data
if (!payload?.sendEmail) {
return
}
const user = await this.userModel.findById(payload.userId)
if (!user?.email) {
return
}
const subject = this.subjectForType(payload.type, payload.title)
const ctaUrlBase = process.env.FRONTEND_URL || 'https://app.textbee.dev'
const isEmailVerification = payload.type === 'email_verification_required'
const ctaUrl = isEmailVerification
? `${ctaUrlBase}/dashboard/account`
: 'https://textbee.dev/#pricing'
const ctaLabel = isEmailVerification ? 'Verify your email' : 'View plans & pricing'
await this.mailService.sendEmailFromTemplate({
to: user.email,
subject,
template: 'billing-notification',
context: {
name: user.name?.split(' ')?.[0] || 'there',
title: payload.title,
message: payload.message,
ctaLabel,
ctaUrl,
brandName: 'textbee.dev',
},
from: undefined,
})
await this.notificationModel.updateOne(
{ _id: payload.notificationId },
{ $inc: { sentEmailCount: 1 }, $set: { lastEmailSentAt: new Date() } },
)
}
private subjectForType(type: string, fallback: string) {
switch (type) {
case 'daily_limit_reached':
return 'Daily SMS limit reached — action required'
case 'monthly_limit_reached':
return 'Monthly SMS limit reached — action required'
case 'bulk_sms_limit_reached':
return 'Bulk send limit exceeded'
case 'daily_limit_approaching':
return 'Heads up: daily usage nearing your limit'
case 'monthly_limit_approaching':
return 'Heads up: monthly usage nearing your limit'
case 'email_verification_required':
return 'Verify your email to keep using textbee'
default:
return fallback || 'Account notification'
}
}
}