From 455696275b4ec918f0dfc4e673ed908d795f3735 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 06:18:30 +0300 Subject: [PATCH] feat(api): billing notifications --- api/package.json | 1 + api/pnpm-lock.yaml | 20 +++ api/src/app.module.ts | 2 + .../billing/billing-notifications.listener.ts | 77 +++++++++++ .../billing/billing-notifications.service.ts | 103 ++++++++++++++ api/src/billing/billing.module.ts | 8 +- api/src/billing/billing.service.ts | 129 +++++++++++++++--- .../schemas/billing-notification.schema.ts | 58 ++++++++ 8 files changed, 380 insertions(+), 18 deletions(-) create mode 100644 api/src/billing/billing-notifications.listener.ts create mode 100644 api/src/billing/billing-notifications.service.ts create mode 100644 api/src/billing/schemas/billing-notification.schema.ts diff --git a/api/package.json b/api/package.json index 84e21bc..b897a34 100644 --- a/api/package.json +++ b/api/package.json @@ -25,6 +25,7 @@ "@nestjs/common": "^10.4.5", "@nestjs/config": "^4.0.1", "@nestjs/core": "^10.4.5", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "^2.1.0", "@nestjs/mongoose": "^10.0.10", diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 95e4ae3..1415737 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@nestjs/core': specifier: ^10.4.5 version: 10.4.5(@nestjs/common@10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1(@nestjs/common@10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) '@nestjs/jwt': specifier: ^10.2.0 version: 10.2.0(@nestjs/common@10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)) @@ -851,6 +854,12 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/jwt@10.2.0': resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} peerDependencies: @@ -2258,6 +2267,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -5613,6 +5625,12 @@ snapshots: transitivePeerDependencies: - encoding + '@nestjs/event-emitter@3.0.1(@nestjs/common@10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)': + dependencies: + '@nestjs/common': 10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1) + eventemitter2: 6.4.9 + '@nestjs/jwt@10.2.0(@nestjs/common@10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))': dependencies: '@nestjs/common': 10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -7315,6 +7333,8 @@ snapshots: event-target-shim@5.0.1: optional: true + eventemitter2@6.4.9: {} + events@3.3.0: {} execa@5.1.1: diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 722e158..e47e65e 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -19,6 +19,7 @@ import { BillingModule } from './billing/billing.module' import { ConfigModule, ConfigService } from '@nestjs/config' import { BullModule } from '@nestjs/bull' import { SupportModule } from './support/support.module' +import { EventEmitterModule } from '@nestjs/event-emitter' @Injectable() export class LoggerMiddleware implements NestMiddleware { @@ -36,6 +37,7 @@ export class LoggerMiddleware implements NestMiddleware { ConfigModule.forRoot({ isGlobal: true, }), + EventEmitterModule.forRoot(), ThrottlerModule.forRoot([ { ttl: 60000, diff --git a/api/src/billing/billing-notifications.listener.ts b/api/src/billing/billing-notifications.listener.ts new file mode 100644 index 0000000..c1c1954 --- /dev/null +++ b/api/src/billing/billing-notifications.listener.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { InjectModel } from '@nestjs/mongoose' +import { Model, Types } from 'mongoose' +import { MailService } from '../mail/mail.service' +import { + BillingNotification, + BillingNotificationDocument, +} from './schemas/billing-notification.schema' +import { User, UserDocument } from '../users/schemas/user.schema' + +@Injectable() +export class BillingNotificationsListener { + constructor( + private readonly mailService: MailService, + @InjectModel(BillingNotification.name) + private readonly notificationModel: Model, + @InjectModel(User.name) + private readonly userModel: Model, + ) {} + + @OnEvent('billing.notification.created', { async: true }) + async handleCreatedEvent(payload: { + notificationId: Types.ObjectId + userId: Types.ObjectId + type: string + title: string + message: string + meta: Record + createdAt: Date + sendEmail?: boolean + }) { + if (!payload?.sendEmail) { + return + } + + const user = await this.userModel.findById(payload.userId) + if (!user?.email) { + return + } + + const html = this.buildEmailHtml({ + name: user.name?.split(' ')?.[0] || 'there', + title: payload.title, + message: payload.message, + }) + + await this.mailService.sendEmail({ + to: user.email, + subject: payload.title, + html, + from: undefined, + }) + + await this.notificationModel.updateOne( + { _id: payload.notificationId }, + { $inc: { sentEmailCount: 1 }, $set: { lastEmailSentAt: new Date() } }, + ) + } + + private buildEmailHtml({ + name, + title, + message, + }: { + name: string + title: string + message: string + }) { + return ` +
+

${title}

+

Hi ${name}, ${message}

+
+ ` + } +} diff --git a/api/src/billing/billing-notifications.service.ts b/api/src/billing/billing-notifications.service.ts new file mode 100644 index 0000000..0298d2a --- /dev/null +++ b/api/src/billing/billing-notifications.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Model, Types } from 'mongoose' +import { + BillingNotification, + BillingNotificationDocument, + BillingNotificationSchema, + BillingNotificationType, +} from './schemas/billing-notification.schema' + +type NotifyOnceInput = { + userId: Types.ObjectId | string + type: BillingNotificationType + title: string + message: string + meta?: Record + sendEmail?: boolean +} + +@Injectable() +export class BillingNotificationsService { + constructor( + @InjectModel(BillingNotification.name) + private readonly notificationModel: Model, + private readonly eventEmitter: EventEmitter2, + ) {} + + async notifyOnce({ userId, type, title, message, meta = {}, sendEmail = true }: NotifyOnceInput) { + const recent = await this.findRecentSimilar(userId, type) + if (recent) { + return recent + } + + const created = await this.createNotification(userId, type, title, message, meta) + + this.emitCreatedEvent(created, { sendEmail }) + + return created + } + + async listForUser(userId: Types.ObjectId | string, { limit = 50 } = {}) { + return this.notificationModel + .find({ user: new Types.ObjectId(userId) }) + .sort({ createdAt: -1 }) + .limit(limit) + } + + private getDedupeWindowMs(type: BillingNotificationType) { + const hours = { + [BillingNotificationType.EMAIL_VERIFICATION_REQUIRED]: 24, + [BillingNotificationType.DAILY_LIMIT_REACHED]: 12, + [BillingNotificationType.MONTHLY_LIMIT_REACHED]: 48, + [BillingNotificationType.BULK_SMS_LIMIT_REACHED]: 12, + [BillingNotificationType.DAILY_LIMIT_APPROACHING]: 24, + [BillingNotificationType.MONTHLY_LIMIT_APPROACHING]: 48, + }[type] + + return hours * 60 * 60 * 1000 + } + + private async findRecentSimilar(userId: Types.ObjectId | string, type: BillingNotificationType) { + const since = new Date(Date.now() - this.getDedupeWindowMs(type)) + return this.notificationModel.findOne({ + user: new Types.ObjectId(userId), + type, + createdAt: { $gte: since }, + }) + } + + private async createNotification( + userId: Types.ObjectId | string, + type: BillingNotificationType, + title: string, + message: string, + meta: Record, + ) { + return this.notificationModel.create({ + user: new Types.ObjectId(userId), + type, + title, + message, + meta, + }) + } + + 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 baeb02f..cb85c44 100644 --- a/api/src/billing/billing.module.ts +++ b/api/src/billing/billing.module.ts @@ -14,6 +14,9 @@ import { MailModule } from 'src/mail/mail.module' import { PolarWebhookPayload, PolarWebhookPayloadSchema } from './schemas/polar-webhook-payload.schema' 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' @Module({ imports: [ @@ -23,6 +26,7 @@ import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-sessi { name: PolarWebhookPayload.name, schema: PolarWebhookPayloadSchema }, { name: Device.name, schema: DeviceSchema }, { name: CheckoutSession.name, schema: CheckoutSessionSchema }, + { name: BillingNotification.name, schema: BillingNotificationSchema }, ]), forwardRef(() => AuthModule), forwardRef(() => UsersModule), @@ -30,7 +34,7 @@ import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-sessi MailModule, ], controllers: [BillingController], - providers: [BillingService, AbandonedCheckoutService], - exports: [BillingService, AbandonedCheckoutService], + providers: [BillingService, AbandonedCheckoutService, BillingNotificationsService, BillingNotificationsListener], + exports: [BillingService, AbandonedCheckoutService, BillingNotificationsService], }) export class BillingModule {} diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index cf68e76..6a45d6a 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -18,6 +18,7 @@ import { PolarWebhookPayloadDocument, } from './schemas/polar-webhook-payload.schema' import { CheckoutSession, CheckoutSessionDocument } from './schemas/checkout-session.schema' +import { BillingNotificationsService, BillingNotificationType } from './billing-notifications.service' @Injectable() export class BillingService { @@ -34,6 +35,7 @@ export class BillingService { private polarWebhookPayloadModel: Model, @InjectModel(CheckoutSession.name) private checkoutSessionModel: Model, + private readonly billingNotifications: BillingNotificationsService, ) { this.polarApi = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN ?? '', @@ -74,6 +76,39 @@ export class BillingService { if (subscription) { const plan = subscription.plan + // fire-and-forget: approaching threshold notifications + try { + if (plan?.dailyLimit && plan.dailyLimit > 0) { + const dailyPct = processedSmsToday / plan.dailyLimit + if (dailyPct >= 0.8 && processedSmsToday < plan.dailyLimit) { + this.billingNotifications + .notifyOnce({ + userId: user._id, + type: BillingNotificationType.DAILY_LIMIT_APPROACHING, + title: 'Daily limit approaching', + message: `You have used ${Math.round(dailyPct * 100)}% of your daily limit. ${plan.dailyLimit - processedSmsToday} messages remaining today.`, + meta: { processedSmsToday, dailyLimit: plan.dailyLimit }, + sendEmail: true, + }) + .catch(() => {}) + } + } + if (plan?.monthlyLimit && plan.monthlyLimit > 0) { + const monthlyPct = processedSmsLastMonth / plan.monthlyLimit + if (monthlyPct >= 0.8 && processedSmsLastMonth < plan.monthlyLimit) { + this.billingNotifications + .notifyOnce({ + userId: user._id, + type: BillingNotificationType.MONTHLY_LIMIT_APPROACHING, + title: 'Monthly limit approaching', + message: `You have used ${Math.round(monthlyPct * 100)}% of your monthly limit. ${plan.monthlyLimit - processedSmsLastMonth} messages remaining this month.`, + meta: { processedSmsLastMonth, monthlyLimit: plan.monthlyLimit }, + sendEmail: true, + }) + .catch(() => {}) + } + } + } catch {} return { ...subscription.toObject(), usage: { @@ -91,6 +126,40 @@ export class BillingService { const plan = await this.planModel.findOne({ name: 'free' }) + // fire-and-forget: approaching threshold notifications + try { + if (plan?.dailyLimit && plan.dailyLimit > 0) { + const dailyPct = processedSmsToday / plan.dailyLimit + if (dailyPct >= 0.8 && processedSmsToday < plan.dailyLimit) { + this.billingNotifications + .notifyOnce({ + userId: user._id, + type: BillingNotificationType.DAILY_LIMIT_APPROACHING, + title: 'Daily limit approaching', + message: `You have used ${Math.round(dailyPct * 100)}% of your daily limit. ${plan.dailyLimit - processedSmsToday} messages remaining today.`, + meta: { processedSmsToday, dailyLimit: plan.dailyLimit }, + sendEmail: true, + }) + .catch(() => {}) + } + } + if (plan?.monthlyLimit && plan.monthlyLimit > 0) { + const monthlyPct = processedSmsLastMonth / plan.monthlyLimit + if (monthlyPct >= 0.8 && processedSmsLastMonth < plan.monthlyLimit) { + this.billingNotifications + .notifyOnce({ + userId: user._id, + type: BillingNotificationType.MONTHLY_LIMIT_APPROACHING, + title: 'Monthly limit approaching', + message: `You have used ${Math.round(monthlyPct * 100)}% of your monthly limit. ${plan.monthlyLimit - processedSmsLastMonth} messages remaining this month.`, + meta: { processedSmsLastMonth, monthlyLimit: plan.monthlyLimit }, + sendEmail: true, + }) + .catch(() => {}) + } + } + } catch {} + return { plan, isActive: true, @@ -416,29 +485,32 @@ export class BillingService { }, }) + let dailyExceeded = false + let monthlyExceeded = false + let bulkExceeded = false + if (['send_sms', 'receive_sms', 'bulk_send_sms'].includes(action)) { - // check daily limit - if ( - plan.dailyLimit !== -1 && - processedSmsToday + value > plan.dailyLimit - ) { + const dailyFinite = plan.dailyLimit !== -1 + const monthlyFinite = plan.monthlyLimit !== -1 + + // exceeded checks + dailyExceeded = dailyFinite && processedSmsToday + value > plan.dailyLimit + monthlyExceeded = monthlyFinite && processedSmsLastMonth + value > plan.monthlyLimit + bulkExceeded = plan.bulkSendLimit !== -1 && value > plan.bulkSendLimit + + if (dailyExceeded) { hasReachedLimit = true - message = `You have reached your daily limit, you only have ${plan.dailyLimit - processedSmsToday} remaining` + message = `Daily limit reached. ${Math.max(0, plan.dailyLimit - processedSmsToday)} remaining today.` } - // check monthly limit - if ( - plan.monthlyLimit !== -1 && - processedSmsLastMonth + value > plan.monthlyLimit - ) { + if (monthlyExceeded) { hasReachedLimit = true - message = `You have reached your monthly limit, you only have ${plan.monthlyLimit - processedSmsLastMonth} remaining` + message = `Monthly limit reached. ${Math.max(0, plan.monthlyLimit - processedSmsLastMonth)} remaining this month.` } - // check bulk send limit - if (plan.bulkSendLimit !== -1 && value > plan.bulkSendLimit) { + if (bulkExceeded) { hasReachedLimit = true - message = `You can only send ${plan.bulkSendLimit} sms at a time` + message = `Bulk send limit is ${plan.bulkSendLimit} messages per batch.` } } @@ -460,7 +532,32 @@ export class BillingService { monthlyLimit: plan.monthlyLimit, }), ) - + // send a deduped notification (single call here for minimal change) + let type: BillingNotificationType + if (dailyExceeded) { + type = BillingNotificationType.DAILY_LIMIT_REACHED + } else if (monthlyExceeded) { + type = BillingNotificationType.MONTHLY_LIMIT_REACHED + } else if (bulkExceeded) { + type = BillingNotificationType.BULK_SMS_LIMIT_REACHED + } + if (type) { + await this.billingNotifications.notifyOnce({ + userId: user._id, + type, + title: message.split('.')[0], + message, + meta: { + processedSmsToday, + processedSmsLastMonth, + attempted: value, + dailyLimit: plan.dailyLimit, + monthlyLimit: plan.monthlyLimit, + bulkSendLimit: plan.bulkSendLimit, + }, + sendEmail: true, + }) + } throw new HttpException( { message: message, diff --git a/api/src/billing/schemas/billing-notification.schema.ts b/api/src/billing/schemas/billing-notification.schema.ts new file mode 100644 index 0000000..71e94ba --- /dev/null +++ b/api/src/billing/schemas/billing-notification.schema.ts @@ -0,0 +1,58 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Document, Types } from 'mongoose' +import { User } from '../../users/schemas/user.schema' + +export type BillingNotificationDocument = BillingNotification & Document + +export enum BillingNotificationType { + EMAIL_VERIFICATION_REQUIRED = 'email_verification_required', + DAILY_LIMIT_REACHED = 'daily_limit_reached', + MONTHLY_LIMIT_REACHED = 'monthly_limit_reached', + BULK_SMS_LIMIT_REACHED = 'bulk_sms_limit_reached', + DAILY_LIMIT_APPROACHING = 'daily_limit_approaching', + MONTHLY_LIMIT_APPROACHING = 'monthly_limit_approaching', +} + +@Schema({ timestamps: true }) +export class BillingNotification { + _id?: Types.ObjectId + + @Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true }) + user: User + + @Prop({ type: String, enum: Object.values(BillingNotificationType), required: true, index: true }) + type: BillingNotificationType + + @Prop({ type: String, required: true }) + title: string + + @Prop({ type: String, required: true }) + message: string + + @Prop({ type: Object, default: {} }) + meta: Record + + @Prop({ type: Date }) + readAt?: Date + + @Prop({ type: Boolean, default: false }) + isDismissed: boolean + + @Prop({ type: Number, default: 0 }) + sentEmailCount: number + + @Prop({ type: Date }) + lastEmailSentAt?: Date + + // present because of timestamps: true + @Prop({ type: Date }) + createdAt?: Date + + @Prop({ type: Date }) + updatedAt?: Date +} + +export const BillingNotificationSchema = + SchemaFactory.createForClass(BillingNotification) + +