From 552a90685aa50ff8143bd2efe020d5ad481b390c Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 06:48:03 +0300 Subject: [PATCH] chore(api): improve billing notifications --- .../billing/billing-notifications.service.ts | 37 +++++--- api/src/billing/billing.module.ts | 14 ++- .../queue/billing-notifications.processor.ts | 92 +++++++++++++++++++ 3 files changed, 126 insertions(+), 17 deletions(-) create mode 100644 api/src/billing/queue/billing-notifications.processor.ts diff --git a/api/src/billing/billing-notifications.service.ts b/api/src/billing/billing-notifications.service.ts index 0298d2a..3bb8e40 100644 --- a/api/src/billing/billing-notifications.service.ts +++ b/api/src/billing/billing-notifications.service.ts @@ -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, - 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 } diff --git a/api/src/billing/billing.module.ts b/api/src/billing/billing.module.ts index cb85c44..49a295a 100644 --- a/api/src/billing/billing.module.ts +++ b/api/src/billing/billing.module.ts @@ -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 {} diff --git a/api/src/billing/queue/billing-notifications.processor.ts b/api/src/billing/queue/billing-notifications.processor.ts new file mode 100644 index 0000000..35fd7a8 --- /dev/null +++ b/api/src/billing/queue/billing-notifications.processor.ts @@ -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, + @InjectModel(User.name) + private readonly userModel: Model, + ) {} + + @Process({ name: 'send', concurrency: 1 }) + async handleSend(job: Job<{ + notificationId: Types.ObjectId + userId: Types.ObjectId + type: string + title: string + message: string + meta: Record + 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' + } + } +} + +