From 455696275b4ec918f0dfc4e673ed908d795f3735 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 06:18:30 +0300 Subject: [PATCH 1/9] 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) + + From e2246cf8e891266f82c53fac8674d19de02ebc3f Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 06:43:51 +0300 Subject: [PATCH 2/9] improve billing notification emails --- .../billing/billing-notifications.listener.ts | 58 ++++++++----- api/src/billing/billing.service.ts | 28 +++--- .../mail/templates/billing-notification.hbs | 87 +++++++++++++++++++ 3 files changed, 138 insertions(+), 35 deletions(-) create mode 100644 api/src/mail/templates/billing-notification.hbs diff --git a/api/src/billing/billing-notifications.listener.ts b/api/src/billing/billing-notifications.listener.ts index c1c1954..e78ed50 100644 --- a/api/src/billing/billing-notifications.listener.ts +++ b/api/src/billing/billing-notifications.listener.ts @@ -39,16 +39,26 @@ export class BillingNotificationsListener { return } - const html = this.buildEmailHtml({ - name: user.name?.split(' ')?.[0] || 'there', - title: payload.title, - message: payload.message, - }) + 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.sendEmail({ + await this.mailService.sendEmailFromTemplate({ to: user.email, - subject: payload.title, - html, + subject, + template: 'billing-notification', + context: { + name: user.name?.split(' ')?.[0] || 'there', + title: payload.title, + message: payload.message, + ctaLabel, + ctaUrl, + brandName: 'textbee.dev', + }, from: undefined, }) @@ -58,20 +68,22 @@ export class BillingNotificationsListener { ) } - private buildEmailHtml({ - name, - title, - message, - }: { - name: string - title: string - message: string - }) { - return ` -
-

${title}

-

Hi ${name}, ${message}

-
- ` + 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' + } } } diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 6a45d6a..af25dab 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -85,8 +85,8 @@ export class BillingService { .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.`, + title: 'You’re nearing today’s SMS limit', + message: `You’ve used ${Math.round(dailyPct * 100)}% of today’s SMS allocation. ${plan.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`, meta: { processedSmsToday, dailyLimit: plan.dailyLimit }, sendEmail: true, }) @@ -100,8 +100,8 @@ export class BillingService { .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.`, + title: 'You’re nearing this month’s SMS limit', + message: `You’ve used ${Math.round(monthlyPct * 100)}% of this month’s SMS allocation. ${plan.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`, meta: { processedSmsLastMonth, monthlyLimit: plan.monthlyLimit }, sendEmail: true, }) @@ -135,8 +135,8 @@ export class BillingService { .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.`, + title: 'You’re nearing today’s SMS limit', + message: `You’ve used ${Math.round(dailyPct * 100)}% of today’s SMS allocation. ${plan.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`, meta: { processedSmsToday, dailyLimit: plan.dailyLimit }, sendEmail: true, }) @@ -150,8 +150,8 @@ export class BillingService { .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.`, + title: 'You’re nearing this month’s SMS limit', + message: `You’ve used ${Math.round(monthlyPct * 100)}% of this month’s SMS allocation. ${plan.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`, meta: { processedSmsLastMonth, monthlyLimit: plan.monthlyLimit }, sendEmail: true, }) @@ -500,17 +500,17 @@ export class BillingService { if (dailyExceeded) { hasReachedLimit = true - message = `Daily limit reached. ${Math.max(0, plan.dailyLimit - processedSmsToday)} remaining today.` + message = `Daily SMS limit reached — you’ve used your full daily allocation. ${Math.max(0, plan.dailyLimit - processedSmsToday)} messages remain for today. Upgrade to increase your daily capacity or try again tomorrow.` } if (monthlyExceeded) { hasReachedLimit = true - message = `Monthly limit reached. ${Math.max(0, plan.monthlyLimit - processedSmsLastMonth)} remaining this month.` + message = `Monthly SMS limit reached — you’ve used this billing period’s allocation. Upgrade to continue sending immediately, or wait for the next billing period.` } if (bulkExceeded) { hasReachedLimit = true - message = `Bulk send limit is ${plan.bulkSendLimit} messages per batch.` + message = `Bulk send limit exceeded — your plan allows up to ${plan.bulkSendLimit} messages per batch. Split your send into smaller batches or upgrade your plan.` } } @@ -534,18 +534,22 @@ export class BillingService { ) // send a deduped notification (single call here for minimal change) let type: BillingNotificationType + let titleForEmail = '' if (dailyExceeded) { type = BillingNotificationType.DAILY_LIMIT_REACHED + titleForEmail = 'Daily SMS limit reached' } else if (monthlyExceeded) { type = BillingNotificationType.MONTHLY_LIMIT_REACHED + titleForEmail = 'Monthly SMS limit reached' } else if (bulkExceeded) { type = BillingNotificationType.BULK_SMS_LIMIT_REACHED + titleForEmail = 'Bulk send limit exceeded' } if (type) { await this.billingNotifications.notifyOnce({ userId: user._id, type, - title: message.split('.')[0], + title: titleForEmail || 'Usage limit notice', message, meta: { processedSmsToday, diff --git a/api/src/mail/templates/billing-notification.hbs b/api/src/mail/templates/billing-notification.hbs new file mode 100644 index 0000000..466cacb --- /dev/null +++ b/api/src/mail/templates/billing-notification.hbs @@ -0,0 +1,87 @@ + + + + + + {{title}} • {{brandName}} + + + + +
Important account update from {{brandName}}
+ + + + + + + + From 552a90685aa50ff8143bd2efe020d5ad481b390c Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 06:48:03 +0300 Subject: [PATCH 3/9] 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' + } + } +} + + From bf66e94e6d8c744836fb9f7eca27e92ade090580 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 06:55:35 +0300 Subject: [PATCH 4/9] chore(api): improve billing threshold calculation --- api/src/billing/billing.service.ts | 160 ++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 47 deletions(-) diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index af25dab..1c8b919 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common' +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model, Types } from 'mongoose' import { Plan, PlanDocument } from './schemas/plan.schema' @@ -17,8 +22,14 @@ import { PolarWebhookPayload, PolarWebhookPayloadDocument, } from './schemas/polar-webhook-payload.schema' -import { CheckoutSession, CheckoutSessionDocument } from './schemas/checkout-session.schema' -import { BillingNotificationsService, BillingNotificationType } from './billing-notifications.service' +import { + CheckoutSession, + CheckoutSessionDocument, +} from './schemas/checkout-session.schema' +import { + BillingNotificationsService, + BillingNotificationType, +} from './billing-notifications.service' @Injectable() export class BillingService { @@ -60,7 +71,7 @@ export class BillingService { // Get user's devices and usage data const userDevices = await this.deviceModel.find({ user: user._id }, '_id') - const deviceIds = userDevices.map(d => d._id) + const deviceIds = userDevices.map((d) => d._id) const processedSmsToday = await this.smsModel.countDocuments({ device: { $in: deviceIds }, @@ -102,7 +113,10 @@ export class BillingService { type: BillingNotificationType.MONTHLY_LIMIT_APPROACHING, title: 'You’re nearing this month’s SMS limit', message: `You’ve used ${Math.round(monthlyPct * 100)}% of this month’s SMS allocation. ${plan.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`, - meta: { processedSmsLastMonth, monthlyLimit: plan.monthlyLimit }, + meta: { + processedSmsLastMonth, + monthlyLimit: plan.monthlyLimit, + }, sendEmail: true, }) .catch(() => {}) @@ -116,11 +130,21 @@ export class BillingService { processedSmsLastMonth, dailyLimit: plan.dailyLimit, monthlyLimit: plan.monthlyLimit, - dailyRemaining: plan.dailyLimit === -1 ? -1 : plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit === -1 ? -1 : plan.monthlyLimit - processedSmsLastMonth, - dailyUsagePercentage: plan.dailyLimit === -1 ? 0 : Math.round((processedSmsToday / plan.dailyLimit) * 100), - monthlyUsagePercentage: plan.monthlyLimit === -1 ? 0 : Math.round((processedSmsLastMonth / plan.monthlyLimit) * 100), - } + dailyRemaining: + plan.dailyLimit === -1 ? -1 : plan.dailyLimit - processedSmsToday, + monthlyRemaining: + plan.monthlyLimit === -1 + ? -1 + : plan.monthlyLimit - processedSmsLastMonth, + dailyUsagePercentage: + plan.dailyLimit === -1 + ? 0 + : Math.round((processedSmsToday / plan.dailyLimit) * 100), + monthlyUsagePercentage: + plan.monthlyLimit === -1 + ? 0 + : Math.round((processedSmsLastMonth / plan.monthlyLimit) * 100), + }, } } @@ -168,11 +192,21 @@ export class BillingService { processedSmsLastMonth, dailyLimit: plan.dailyLimit, monthlyLimit: plan.monthlyLimit, - dailyRemaining: plan.dailyLimit === -1 ? -1 : plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit === -1 ? -1 : plan.monthlyLimit - processedSmsLastMonth, - dailyUsagePercentage: plan.dailyLimit === -1 ? 0 : Math.round((processedSmsToday / plan.dailyLimit) * 100), - monthlyUsagePercentage: plan.monthlyLimit === -1 ? 0 : Math.round((processedSmsLastMonth / plan.monthlyLimit) * 100), - } + dailyRemaining: + plan.dailyLimit === -1 ? -1 : plan.dailyLimit - processedSmsToday, + monthlyRemaining: + plan.monthlyLimit === -1 + ? -1 + : plan.monthlyLimit - processedSmsLastMonth, + dailyUsagePercentage: + plan.dailyLimit === -1 + ? 0 + : Math.round((processedSmsToday / plan.dailyLimit) * 100), + monthlyUsagePercentage: + plan.monthlyLimit === -1 + ? 0 + : Math.round((processedSmsLastMonth / plan.monthlyLimit) * 100), + }, } } @@ -209,7 +243,8 @@ export class BillingService { // const product = await this.polarApi.products.get(selectedPlan.polarProductId) - const discountId = payload.discountId ?? process.env.POLAR_DEFAULT_DISCOUNT_ID + const discountId = + payload.discountId ?? process.env.POLAR_DEFAULT_DISCOUNT_ID try { const checkoutOptions: any = { @@ -239,22 +274,26 @@ export class BillingService { } catch (error) { console.error('failed to get discount', error) } - const checkout = await this.polarApi.checkouts.create(checkoutOptions) - - - this.checkoutSessionModel.updateOne({ - user: user._id, - },{ - user: user._id, - checkoutSessionId: checkout.id, - checkoutUrl: checkout.url, - expiresAt: new Date(checkout.expiresAt), - payload: checkout, - }, { upsert: true }).catch((error) => { - console.error(error) - }) + + this.checkoutSessionModel + .updateOne( + { + user: user._id, + }, + { + user: user._id, + checkoutSessionId: checkout.id, + checkoutUrl: checkout.url, + expiresAt: new Date(checkout.expiresAt), + payload: checkout, + }, + { upsert: true }, + ) + .catch((error) => { + console.error(error) + }) return { redirectUrl: checkout.url } } catch (error) { @@ -472,7 +511,7 @@ export class BillingService { // Get user's devices and then count SMS const userDevices = await this.deviceModel.find({ user: user._id }, '_id') - const deviceIds = userDevices.map(d => d._id) + const deviceIds = userDevices.map((d) => d._id) const processedSmsToday = await this.smsModel.countDocuments({ device: { $in: deviceIds }, @@ -494,8 +533,10 @@ export class BillingService { const monthlyFinite = plan.monthlyLimit !== -1 // exceeded checks - dailyExceeded = dailyFinite && processedSmsToday + value > plan.dailyLimit - monthlyExceeded = monthlyFinite && processedSmsLastMonth + value > plan.monthlyLimit + dailyExceeded = + dailyFinite && processedSmsToday + value > plan.dailyLimit + monthlyExceeded = + monthlyFinite && processedSmsLastMonth + value > plan.monthlyLimit bulkExceeded = plan.bulkSendLimit !== -1 && value > plan.bulkSendLimit if (dailyExceeded) { @@ -562,18 +603,41 @@ export class BillingService { sendEmail: true, }) } - throw new HttpException( - { - message: message, - hasReachedLimit: true, - dailyLimit: plan.dailyLimit, - dailyRemaining: plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, - bulkSendLimit: plan.bulkSendLimit, - monthlyLimit: plan.monthlyLimit, - }, - HttpStatus.TOO_MANY_REQUESTS, - ) + + //if plan is pro and monthly limit is exceeded, give them 30% more monthly limit + if (plan.name?.startsWith('pro') && monthlyExceeded && !dailyExceeded && !bulkExceeded) { + const extendedMonthlyLimit = Math.floor(plan.monthlyLimit * 1.3) + const exceedsExtended = + processedSmsLastMonth + value > extendedMonthlyLimit + if (!exceedsExtended) { + return true + } + throw new HttpException( + { + message: message, + hasReachedLimit: true, + dailyLimit: plan.dailyLimit, + dailyRemaining: plan.dailyLimit - processedSmsToday, + monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, + bulkSendLimit: plan.bulkSendLimit, + monthlyLimit: plan.monthlyLimit, + }, + HttpStatus.TOO_MANY_REQUESTS, + ) + } else { + throw new HttpException( + { + message: message, + hasReachedLimit: true, + dailyLimit: plan.dailyLimit, + dailyRemaining: plan.dailyLimit - processedSmsToday, + monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, + bulkSendLimit: plan.bulkSendLimit, + monthlyLimit: plan.monthlyLimit, + }, + HttpStatus.TOO_MANY_REQUESTS, + ) + } } return true @@ -596,8 +660,10 @@ export class BillingService { const plan = await this.planModel.findById(subscription.plan) // First get all devices belonging to the user - const userDevices = await this.deviceModel.find({ user: new Types.ObjectId(userId) }).select('_id') - const deviceIds = userDevices.map(device => device._id) + const userDevices = await this.deviceModel + .find({ user: new Types.ObjectId(userId) }) + .select('_id') + const deviceIds = userDevices.map((device) => device._id) const processedSmsToday = await this.smsModel.countDocuments({ device: { $in: deviceIds }, From c26ac378f6f57e288328daef3b920c84fcd1db57 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 07:22:21 +0300 Subject: [PATCH 5/9] fix(api): fix duplicate billing notification emails --- .../billing/billing-notifications.service.ts | 62 ++++++++----------- api/src/billing/billing.service.ts | 44 ++++++------- .../queue/billing-notifications.processor.ts | 22 +++++++ 3 files changed, 66 insertions(+), 62 deletions(-) diff --git a/api/src/billing/billing-notifications.service.ts b/api/src/billing/billing-notifications.service.ts index 3bb8e40..cab4383 100644 --- a/api/src/billing/billing-notifications.service.ts +++ b/api/src/billing/billing-notifications.service.ts @@ -28,23 +28,35 @@ export class BillingNotificationsService { ) {} async notifyOnce({ userId, type, title, message, meta = {}, sendEmail = true }: NotifyOnceInput) { - const recent = await this.findRecentSimilar(userId, type) - if (recent) { - return recent + const windowMs = this.getDedupeWindowMs(type) + const existing = await this.notificationModel.findOne({ + user: new Types.ObjectId(userId), + type, + }) + + if (existing) { + const lastSentAt = existing.lastEmailSentAt || existing.createdAt + if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) { + return existing + } } - const created = await this.createNotification(userId, type, title, message, meta) + const updated = await this.notificationModel.findOneAndUpdate( + { user: new Types.ObjectId(userId), type }, + { $set: { title, message, meta }, $setOnInsert: { user: new Types.ObjectId(userId), type } }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) 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, + notificationId: updated._id, + userId: updated.user, + type: updated.type, + title: updated.title, + message: updated.message, + meta: updated.meta, + createdAt: updated.createdAt, sendEmail, }, { @@ -52,10 +64,11 @@ export class BillingNotificationsService { attempts: 3, removeOnComplete: true, backoff: { type: 'exponential', delay: 2000 }, + jobId: updated._id.toString(), }, ) - return created + return updated } async listForUser(userId: Types.ObjectId | string, { limit = 50 } = {}) { @@ -78,30 +91,7 @@ export class BillingNotificationsService { 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, - }) - } + // upsert-based single-document per user+type; dedupe controlled by window } diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 1c8b919..18e09c7 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -605,39 +605,31 @@ export class BillingService { } //if plan is pro and monthly limit is exceeded, give them 30% more monthly limit - if (plan.name?.startsWith('pro') && monthlyExceeded && !dailyExceeded && !bulkExceeded) { + if ( + plan.name?.startsWith('pro') && + monthlyExceeded && + !dailyExceeded && + !bulkExceeded + ) { const extendedMonthlyLimit = Math.floor(plan.monthlyLimit * 1.3) const exceedsExtended = processedSmsLastMonth + value > extendedMonthlyLimit if (!exceedsExtended) { return true } - throw new HttpException( - { - message: message, - hasReachedLimit: true, - dailyLimit: plan.dailyLimit, - dailyRemaining: plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, - bulkSendLimit: plan.bulkSendLimit, - monthlyLimit: plan.monthlyLimit, - }, - HttpStatus.TOO_MANY_REQUESTS, - ) - } else { - throw new HttpException( - { - message: message, - hasReachedLimit: true, - dailyLimit: plan.dailyLimit, - dailyRemaining: plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, - bulkSendLimit: plan.bulkSendLimit, - monthlyLimit: plan.monthlyLimit, - }, - HttpStatus.TOO_MANY_REQUESTS, - ) } + throw new HttpException( + { + message: message, + hasReachedLimit: true, + dailyLimit: plan.dailyLimit, + dailyRemaining: plan.dailyLimit - processedSmsToday, + monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, + bulkSendLimit: plan.bulkSendLimit, + monthlyLimit: plan.monthlyLimit, + }, + HttpStatus.TOO_MANY_REQUESTS, + ) } return true diff --git a/api/src/billing/queue/billing-notifications.processor.ts b/api/src/billing/queue/billing-notifications.processor.ts index 35fd7a8..18ab749 100644 --- a/api/src/billing/queue/billing-notifications.processor.ts +++ b/api/src/billing/queue/billing-notifications.processor.ts @@ -40,6 +40,15 @@ export class BillingNotificationsProcessor { return } + // Ensure we do not resend within the dedupe window + const notif = await this.notificationModel.findById(payload.notificationId) + if (!notif) return + const windowMs = this.getDedupeWindowMs(payload.type as any) + const lastSentAt = notif.lastEmailSentAt || notif.createdAt + if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) { + 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' @@ -69,6 +78,19 @@ export class BillingNotificationsProcessor { ) } + private getDedupeWindowMs(type: string) { + const map: Record = { + email_verification_required: 24, + daily_limit_reached: 12, + monthly_limit_reached: 48, + bulk_sms_limit_reached: 12, + daily_limit_approaching: 24, + monthly_limit_approaching: 48, + } + const hours = map[type] ?? 24 + return hours * 60 * 60 * 1000 + } + private subjectForType(type: string, fallback: string) { switch (type) { case 'daily_limit_reached': From 91ac0b3eb0174dee14a49dcf9f13940394a448ab Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 10:08:25 +0300 Subject: [PATCH 6/9] fix(api): fix billing email notification bug --- api/src/billing/billing-notifications.service.ts | 2 +- api/src/billing/queue/billing-notifications.processor.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/billing/billing-notifications.service.ts b/api/src/billing/billing-notifications.service.ts index cab4383..8134b96 100644 --- a/api/src/billing/billing-notifications.service.ts +++ b/api/src/billing/billing-notifications.service.ts @@ -35,7 +35,7 @@ export class BillingNotificationsService { }) if (existing) { - const lastSentAt = existing.lastEmailSentAt || existing.createdAt + const lastSentAt = existing.lastEmailSentAt if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) { return existing } diff --git a/api/src/billing/queue/billing-notifications.processor.ts b/api/src/billing/queue/billing-notifications.processor.ts index 18ab749..ec8247c 100644 --- a/api/src/billing/queue/billing-notifications.processor.ts +++ b/api/src/billing/queue/billing-notifications.processor.ts @@ -44,7 +44,7 @@ export class BillingNotificationsProcessor { const notif = await this.notificationModel.findById(payload.notificationId) if (!notif) return const windowMs = this.getDedupeWindowMs(payload.type as any) - const lastSentAt = notif.lastEmailSentAt || notif.createdAt + const lastSentAt = notif.lastEmailSentAt if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) { return } From fd5ba1c5386d336117c59859fbeab66e21147d72 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 10:36:24 +0300 Subject: [PATCH 7/9] chore(api): update billing notification queue config --- api/src/billing/billing-notifications.service.ts | 2 +- api/src/billing/billing.module.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/src/billing/billing-notifications.service.ts b/api/src/billing/billing-notifications.service.ts index 8134b96..739b66d 100644 --- a/api/src/billing/billing-notifications.service.ts +++ b/api/src/billing/billing-notifications.service.ts @@ -62,7 +62,7 @@ export class BillingNotificationsService { { delay: 30000, attempts: 3, - removeOnComplete: true, + removeOnComplete: false, backoff: { type: 'exponential', delay: 2000 }, jobId: updated._id.toString(), }, diff --git a/api/src/billing/billing.module.ts b/api/src/billing/billing.module.ts index 49a295a..0ae25f0 100644 --- a/api/src/billing/billing.module.ts +++ b/api/src/billing/billing.module.ts @@ -26,7 +26,11 @@ import { BillingNotificationsProcessor } from 'src/billing/queue/billing-notific name: 'billing-notifications', defaultJobOptions: { attempts: 2, - removeOnComplete: true, + backoff: { + type: 'exponential', + delay: 1000, + }, + removeOnComplete: false, removeOnFail: false, }, }), From 3ea8c0d5a0c65bbada11d2c98616205316d81ca0 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 16 Nov 2025 07:01:06 +0300 Subject: [PATCH 8/9] chore(web): improve checkout error message display --- web/app/(app)/checkout/[planName]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx index 5f194a6..a4c10ea 100644 --- a/web/app/(app)/checkout/[planName]/page.tsx +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -29,8 +29,8 @@ export default function CheckoutPage({ params }) { if (retries > 0) { initiateCheckout(retries - 1) } else { - setError('Failed to create checkout session. Please try again.') - console.error(error) + setError(error.response?.data?.message || 'Failed to create checkout session. Please try again or contact billing@textbee.dev.') + console.error(error.response?.data?.message) } } }, From 130a6934d64003d8b37700799afb98595e52e02c Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 16 Nov 2025 07:02:47 +0300 Subject: [PATCH 9/9] feat(api): support billing limits override --- api/src/billing/billing.service.ts | 258 +++++++++++++++++++---------- 1 file changed, 167 insertions(+), 91 deletions(-) diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 18e09c7..886ec93 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -87,35 +87,46 @@ export class BillingService { if (subscription) { const plan = subscription.plan - // fire-and-forget: approaching threshold notifications + const effectiveLimits = this.getEffectiveLimits(subscription, plan) + try { - if (plan?.dailyLimit && plan.dailyLimit > 0) { - const dailyPct = processedSmsToday / plan.dailyLimit - if (dailyPct >= 0.8 && processedSmsToday < plan.dailyLimit) { + if (effectiveLimits.dailyLimit && effectiveLimits.dailyLimit > 0) { + const dailyPct = processedSmsToday / effectiveLimits.dailyLimit + if ( + dailyPct >= 0.8 && + processedSmsToday < effectiveLimits.dailyLimit + ) { this.billingNotifications .notifyOnce({ userId: user._id, type: BillingNotificationType.DAILY_LIMIT_APPROACHING, - title: 'You’re nearing today’s SMS limit', - message: `You’ve used ${Math.round(dailyPct * 100)}% of today’s SMS allocation. ${plan.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`, - meta: { processedSmsToday, dailyLimit: plan.dailyLimit }, + title: "You're nearing today's SMS limit", + message: `You've used ${Math.round(dailyPct * 100)}% of today's SMS allocation. ${effectiveLimits.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`, + meta: { + processedSmsToday, + dailyLimit: effectiveLimits.dailyLimit, + }, sendEmail: true, }) .catch(() => {}) } } - if (plan?.monthlyLimit && plan.monthlyLimit > 0) { - const monthlyPct = processedSmsLastMonth / plan.monthlyLimit - if (monthlyPct >= 0.8 && processedSmsLastMonth < plan.monthlyLimit) { + if (effectiveLimits.monthlyLimit && effectiveLimits.monthlyLimit > 0) { + const monthlyPct = + processedSmsLastMonth / effectiveLimits.monthlyLimit + if ( + monthlyPct >= 0.8 && + processedSmsLastMonth < effectiveLimits.monthlyLimit + ) { this.billingNotifications .notifyOnce({ userId: user._id, type: BillingNotificationType.MONTHLY_LIMIT_APPROACHING, - title: 'You’re nearing this month’s SMS limit', - message: `You’ve used ${Math.round(monthlyPct * 100)}% of this month’s SMS allocation. ${plan.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`, + title: "You're nearing this month's SMS limit", + message: `You've used ${Math.round(monthlyPct * 100)}% of this month's SMS allocation. ${effectiveLimits.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`, meta: { processedSmsLastMonth, - monthlyLimit: plan.monthlyLimit, + monthlyLimit: effectiveLimits.monthlyLimit, }, sendEmail: true, }) @@ -128,55 +139,71 @@ export class BillingService { usage: { processedSmsToday, processedSmsLastMonth, - dailyLimit: plan.dailyLimit, - monthlyLimit: plan.monthlyLimit, + dailyLimit: effectiveLimits.dailyLimit, + monthlyLimit: effectiveLimits.monthlyLimit, dailyRemaining: - plan.dailyLimit === -1 ? -1 : plan.dailyLimit - processedSmsToday, - monthlyRemaining: - plan.monthlyLimit === -1 + effectiveLimits.dailyLimit === -1 ? -1 - : plan.monthlyLimit - processedSmsLastMonth, + : effectiveLimits.dailyLimit - processedSmsToday, + monthlyRemaining: + effectiveLimits.monthlyLimit === -1 + ? -1 + : effectiveLimits.monthlyLimit - processedSmsLastMonth, dailyUsagePercentage: - plan.dailyLimit === -1 + effectiveLimits.dailyLimit === -1 ? 0 - : Math.round((processedSmsToday / plan.dailyLimit) * 100), + : Math.round( + (processedSmsToday / effectiveLimits.dailyLimit) * 100, + ), monthlyUsagePercentage: - plan.monthlyLimit === -1 + effectiveLimits.monthlyLimit === -1 ? 0 - : Math.round((processedSmsLastMonth / plan.monthlyLimit) * 100), + : Math.round( + (processedSmsLastMonth / effectiveLimits.monthlyLimit) * 100, + ), }, } } const plan = await this.planModel.findOne({ name: 'free' }) + const effectiveLimits = this.getEffectiveLimits(null, 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) { + if (effectiveLimits.dailyLimit && effectiveLimits.dailyLimit > 0) { + const dailyPct = processedSmsToday / effectiveLimits.dailyLimit + if (dailyPct >= 0.8 && processedSmsToday < effectiveLimits.dailyLimit) { this.billingNotifications .notifyOnce({ userId: user._id, type: BillingNotificationType.DAILY_LIMIT_APPROACHING, - title: 'You’re nearing today’s SMS limit', - message: `You’ve used ${Math.round(dailyPct * 100)}% of today’s SMS allocation. ${plan.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`, - meta: { processedSmsToday, dailyLimit: plan.dailyLimit }, + title: "You're nearing today's SMS limit", + message: `You've used ${Math.round(dailyPct * 100)}% of today's SMS allocation. ${effectiveLimits.dailyLimit - processedSmsToday} messages remain for today. Consider upgrading your plan or scheduling sends for later.`, + meta: { + processedSmsToday, + dailyLimit: effectiveLimits.dailyLimit, + }, sendEmail: true, }) .catch(() => {}) } } - if (plan?.monthlyLimit && plan.monthlyLimit > 0) { - const monthlyPct = processedSmsLastMonth / plan.monthlyLimit - if (monthlyPct >= 0.8 && processedSmsLastMonth < plan.monthlyLimit) { + if (effectiveLimits.monthlyLimit && effectiveLimits.monthlyLimit > 0) { + const monthlyPct = processedSmsLastMonth / effectiveLimits.monthlyLimit + if ( + monthlyPct >= 0.8 && + processedSmsLastMonth < effectiveLimits.monthlyLimit + ) { this.billingNotifications .notifyOnce({ userId: user._id, type: BillingNotificationType.MONTHLY_LIMIT_APPROACHING, - title: 'You’re nearing this month’s SMS limit', - message: `You’ve used ${Math.round(monthlyPct * 100)}% of this month’s SMS allocation. ${plan.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`, - meta: { processedSmsLastMonth, monthlyLimit: plan.monthlyLimit }, + title: "You're nearing this month's SMS limit", + message: `You've used ${Math.round(monthlyPct * 100)}% of this month's SMS allocation. ${effectiveLimits.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`, + meta: { + processedSmsLastMonth, + monthlyLimit: effectiveLimits.monthlyLimit, + }, sendEmail: true, }) .catch(() => {}) @@ -190,22 +217,28 @@ export class BillingService { usage: { processedSmsToday, processedSmsLastMonth, - dailyLimit: plan.dailyLimit, - monthlyLimit: plan.monthlyLimit, + dailyLimit: effectiveLimits.dailyLimit, + monthlyLimit: effectiveLimits.monthlyLimit, dailyRemaining: - plan.dailyLimit === -1 ? -1 : plan.dailyLimit - processedSmsToday, - monthlyRemaining: - plan.monthlyLimit === -1 + effectiveLimits.dailyLimit === -1 ? -1 - : plan.monthlyLimit - processedSmsLastMonth, + : effectiveLimits.dailyLimit - processedSmsToday, + monthlyRemaining: + effectiveLimits.monthlyLimit === -1 + ? -1 + : effectiveLimits.monthlyLimit - processedSmsLastMonth, dailyUsagePercentage: - plan.dailyLimit === -1 + effectiveLimits.dailyLimit === -1 ? 0 - : Math.round((processedSmsToday / plan.dailyLimit) * 100), + : Math.round( + (processedSmsToday / effectiveLimits.dailyLimit) * 100, + ), monthlyUsagePercentage: - plan.monthlyLimit === -1 + effectiveLimits.monthlyLimit === -1 ? 0 - : Math.round((processedSmsLastMonth / plan.monthlyLimit) * 100), + : Math.round( + (processedSmsLastMonth / effectiveLimits.monthlyLimit) * 100, + ), }, } } @@ -234,6 +267,14 @@ export class BillingService { name: payload.planName, }) + const currentSubscription = await this.getCurrentSubscription(user) + if (currentSubscription?.plan?.name === payload.planName) { + throw new BadRequestException({ + message: `You are already on ${payload.planName} plan, please contact billing@textbee.dev to get a custom plan`, + code: 'ALREADY_ON_PLAN', + }) + } + if ( !selectedPlan?.polarMonthlyProductId && !selectedPlan?.polarYearlyProductId @@ -358,6 +399,22 @@ export class BillingService { } } + private getEffectiveLimits(subscription: any, plan: any) { + if (!subscription) { + return { + dailyLimit: plan.dailyLimit, + monthlyLimit: plan.monthlyLimit, + bulkSendLimit: plan.bulkSendLimit, + } + } + + return { + dailyLimit: subscription.customDailyLimit ?? plan.dailyLimit, + monthlyLimit: subscription.customMonthlyLimit ?? plan.monthlyLimit, + bulkSendLimit: subscription.customBulkSendLimit ?? plan.bulkSendLimit, + } + } + async getUserLimits(userId: string) { const subscription = await this.subscriptionModel .findOne({ user: new Types.ObjectId(userId), isActive: true }) @@ -366,21 +423,11 @@ export class BillingService { if (!subscription) { // Default to free plan limits const freePlan = await this.planModel.findOne({ name: 'free' }) - return { - dailyLimit: freePlan.dailyLimit, - monthlyLimit: freePlan.monthlyLimit, - bulkSendLimit: freePlan.bulkSendLimit, - } + return this.getEffectiveLimits(null, freePlan) } - // For custom plans, use custom limits if set - return { - dailyLimit: subscription.customDailyLimit || subscription.plan.dailyLimit, - monthlyLimit: - subscription.customMonthlyLimit || subscription.plan.monthlyLimit, - bulkSendLimit: - subscription.customBulkSendLimit || subscription.plan.bulkSendLimit, - } + // Use custom limits if set, otherwise fall back to plan limits + return this.getEffectiveLimits(subscription, subscription.plan) } async switchPlan({ @@ -501,9 +548,18 @@ export class BillingService { plan = await this.planModel.findById(subscription.plan) } + const effectiveLimits = this.getEffectiveLimits(subscription, plan) + if (plan.name?.startsWith('custom')) { - // TODO: for now custom plans are unlimited - return true + // For custom plans, check if custom limits are set to unlimited (-1) + if ( + effectiveLimits.dailyLimit === -1 && + effectiveLimits.monthlyLimit === -1 && + effectiveLimits.bulkSendLimit === -1 + ) { + return true + } + // Otherwise, continue with limit checks using effective limits } let hasReachedLimit = false @@ -529,29 +585,32 @@ export class BillingService { let bulkExceeded = false if (['send_sms', 'receive_sms', 'bulk_send_sms'].includes(action)) { - const dailyFinite = plan.dailyLimit !== -1 - const monthlyFinite = plan.monthlyLimit !== -1 + const dailyFinite = effectiveLimits.dailyLimit !== -1 + const monthlyFinite = effectiveLimits.monthlyLimit !== -1 // exceeded checks dailyExceeded = - dailyFinite && processedSmsToday + value > plan.dailyLimit + dailyFinite && processedSmsToday + value > effectiveLimits.dailyLimit monthlyExceeded = - monthlyFinite && processedSmsLastMonth + value > plan.monthlyLimit - bulkExceeded = plan.bulkSendLimit !== -1 && value > plan.bulkSendLimit + monthlyFinite && + processedSmsLastMonth + value > effectiveLimits.monthlyLimit + bulkExceeded = + effectiveLimits.bulkSendLimit !== -1 && + value > effectiveLimits.bulkSendLimit if (dailyExceeded) { hasReachedLimit = true - message = `Daily SMS limit reached — you’ve used your full daily allocation. ${Math.max(0, plan.dailyLimit - processedSmsToday)} messages remain for today. Upgrade to increase your daily capacity or try again tomorrow.` + message = `Daily SMS limit reached — you've used your full daily allocation. ${Math.max(0, effectiveLimits.dailyLimit - processedSmsToday)} messages remain for today. Upgrade to increase your daily capacity or try again tomorrow.` } if (monthlyExceeded) { hasReachedLimit = true - message = `Monthly SMS limit reached — you’ve used this billing period’s allocation. Upgrade to continue sending immediately, or wait for the next billing period.` + message = `Monthly SMS limit reached — you've used this billing period's allocation. Upgrade to continue sending immediately, or wait for the next billing period.` } if (bulkExceeded) { hasReachedLimit = true - message = `Bulk send limit exceeded — your plan allows up to ${plan.bulkSendLimit} messages per batch. Split your send into smaller batches or upgrade your plan.` + message = `Bulk send limit exceeded — your plan allows up to ${effectiveLimits.bulkSendLimit} messages per batch. Split your send into smaller batches or upgrade your plan.` } } @@ -566,14 +625,15 @@ export class BillingService { value, message, hasReachedLimit: true, - dailyLimit: plan.dailyLimit, - dailyRemaining: plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, - bulkSendLimit: plan.bulkSendLimit, - monthlyLimit: plan.monthlyLimit, + dailyLimit: effectiveLimits.dailyLimit, + dailyRemaining: effectiveLimits.dailyLimit - processedSmsToday, + monthlyRemaining: + effectiveLimits.monthlyLimit - processedSmsLastMonth, + bulkSendLimit: effectiveLimits.bulkSendLimit, + monthlyLimit: effectiveLimits.monthlyLimit, }), ) - // send a deduped notification (single call here for minimal change) + let type: BillingNotificationType let titleForEmail = '' if (dailyExceeded) { @@ -596,22 +656,24 @@ export class BillingService { processedSmsToday, processedSmsLastMonth, attempted: value, - dailyLimit: plan.dailyLimit, - monthlyLimit: plan.monthlyLimit, - bulkSendLimit: plan.bulkSendLimit, + dailyLimit: effectiveLimits.dailyLimit, + monthlyLimit: effectiveLimits.monthlyLimit, + bulkSendLimit: effectiveLimits.bulkSendLimit, }, sendEmail: true, }) } - //if plan is pro and monthly limit is exceeded, give them 30% more monthly limit + // if plan is not free and monthly limit is exceeded, give them 80% more monthly limit if ( - plan.name?.startsWith('pro') && + plan.name !== 'free' && monthlyExceeded && !dailyExceeded && !bulkExceeded ) { - const extendedMonthlyLimit = Math.floor(plan.monthlyLimit * 1.3) + const extendedMonthlyLimit = Math.floor( + effectiveLimits.monthlyLimit * 1.8, + ) const exceedsExtended = processedSmsLastMonth + value > extendedMonthlyLimit if (!exceedsExtended) { @@ -622,11 +684,12 @@ export class BillingService { { message: message, hasReachedLimit: true, - dailyLimit: plan.dailyLimit, - dailyRemaining: plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, - bulkSendLimit: plan.bulkSendLimit, - monthlyLimit: plan.monthlyLimit, + dailyLimit: effectiveLimits.dailyLimit, + dailyRemaining: effectiveLimits.dailyLimit - processedSmsToday, + monthlyRemaining: + effectiveLimits.monthlyLimit - processedSmsLastMonth, + bulkSendLimit: effectiveLimits.bulkSendLimit, + monthlyLimit: effectiveLimits.monthlyLimit, }, HttpStatus.TOO_MANY_REQUESTS, ) @@ -649,7 +712,14 @@ export class BillingService { isActive: true, }) - const plan = await this.planModel.findById(subscription.plan) + let plan: PlanDocument + if (!subscription) { + plan = await this.planModel.findOne({ name: 'free' }) + } else { + plan = await this.planModel.findById(subscription.plan) + } + + const effectiveLimits = this.getEffectiveLimits(subscription, plan) // First get all devices belonging to the user const userDevices = await this.deviceModel @@ -672,11 +742,17 @@ export class BillingService { return { processedSmsToday, processedSmsLastMonth, - dailyLimit: plan.dailyLimit, - monthlyLimit: plan.monthlyLimit, - bulkSendLimit: plan.bulkSendLimit, - dailyRemaining: plan.dailyLimit - processedSmsToday, - monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth, + dailyLimit: effectiveLimits.dailyLimit, + monthlyLimit: effectiveLimits.monthlyLimit, + bulkSendLimit: effectiveLimits.bulkSendLimit, + dailyRemaining: + effectiveLimits.dailyLimit === -1 + ? -1 + : effectiveLimits.dailyLimit - processedSmsToday, + monthlyRemaining: + effectiveLimits.monthlyLimit === -1 + ? -1 + : effectiveLimits.monthlyLimit - processedSmsLastMonth, } }