fix(api): fix duplicate billing notification emails

This commit is contained in:
isra el
2025-10-29 07:22:21 +03:00
parent bf66e94e6d
commit c26ac378f6
3 changed files with 66 additions and 62 deletions

View File

@@ -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<string, any>,
) {
return this.notificationModel.create({
user: new Types.ObjectId(userId),
type,
title,
message,
meta,
})
}
// upsert-based single-document per user+type; dedupe controlled by window
}

View File

@@ -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

View File

@@ -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<string, number> = {
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':