feat(api): billing notifications

This commit is contained in:
isra el
2025-10-29 06:18:30 +03:00
parent 0ffa945668
commit 455696275b
8 changed files with 380 additions and 18 deletions

View File

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

20
api/pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -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<BillingNotificationDocument>,
@InjectModel(User.name)
private readonly userModel: Model<UserDocument>,
) {}
@OnEvent('billing.notification.created', { async: true })
async handleCreatedEvent(payload: {
notificationId: Types.ObjectId
userId: Types.ObjectId
type: string
title: string
message: string
meta: Record<string, any>
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 `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height:1.6;">
<h2 style="margin:0 0 8px 0;">${title}</h2>
<p style="margin:0;">Hi ${name}, ${message}</p>
</div>
`
}
}

View File

@@ -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<string, any>
sendEmail?: boolean
}
@Injectable()
export class BillingNotificationsService {
constructor(
@InjectModel(BillingNotification.name)
private readonly notificationModel: Model<BillingNotificationDocument>,
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<string, any>,
) {
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 }

View File

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

View File

@@ -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<PolarWebhookPayloadDocument>,
@InjectModel(CheckoutSession.name)
private checkoutSessionModel: Model<CheckoutSessionDocument>,
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,

View File

@@ -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<string, any>
@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)