From c26ac378f6f57e288328daef3b920c84fcd1db57 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 29 Oct 2025 07:22:21 +0300 Subject: [PATCH] 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':