Merge pull request #157 from vernu/billing-notification

billing notifications and limit overrides
This commit is contained in:
Israel Abebe
2025-11-16 07:06:04 +03:00
committed by GitHub
11 changed files with 809 additions and 85 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,89 @@
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 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'
}
}
}

View File

@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { InjectQueue } from '@nestjs/bull'
import { Queue } from 'bull'
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>,
@InjectQueue('billing-notifications') private readonly billingQueue: Queue,
) {}
async notifyOnce({ userId, type, title, message, meta = {}, sendEmail = true }: NotifyOnceInput) {
const windowMs = this.getDedupeWindowMs(type)
const existing = await this.notificationModel.findOne({
user: new Types.ObjectId(userId),
type,
})
if (existing) {
const lastSentAt = existing.lastEmailSentAt
if (lastSentAt && lastSentAt.getTime() >= Date.now() - windowMs) {
return existing
}
}
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: updated._id,
userId: updated.user,
type: updated.type,
title: updated.title,
message: updated.message,
meta: updated.meta,
createdAt: updated.createdAt,
sendEmail,
},
{
delay: 30000,
attempts: 3,
removeOnComplete: false,
backoff: { type: 'exponential', delay: 2000 },
jobId: updated._id.toString(),
},
)
return updated
}
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
}
// upsert-based single-document per user+type; dedupe controlled by window
}
export { BillingNotificationType }

View File

@@ -14,15 +14,33 @@ 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'
import { BullModule } from '@nestjs/bull'
import { BillingNotificationsProcessor } from 'src/billing/queue/billing-notifications.processor'
@Module({
imports: [
BullModule.registerQueue({
name: 'billing-notifications',
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: false,
removeOnFail: false,
},
}),
MongooseModule.forFeature([
{ name: Plan.name, schema: PlanSchema },
{ name: Subscription.name, schema: SubscriptionSchema },
{ 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 +48,7 @@ import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-sessi
MailModule,
],
controllers: [BillingController],
providers: [BillingService, AbandonedCheckoutService],
exports: [BillingService, AbandonedCheckoutService],
providers: [BillingService, AbandonedCheckoutService, BillingNotificationsService, BillingNotificationsProcessor],
exports: [BillingService, AbandonedCheckoutService, BillingNotificationsService],
})
export class BillingModule {}

View File

@@ -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,7 +22,14 @@ import {
PolarWebhookPayload,
PolarWebhookPayloadDocument,
} from './schemas/polar-webhook-payload.schema'
import { CheckoutSession, CheckoutSessionDocument } from './schemas/checkout-session.schema'
import {
CheckoutSession,
CheckoutSessionDocument,
} from './schemas/checkout-session.schema'
import {
BillingNotificationsService,
BillingNotificationType,
} from './billing-notifications.service'
@Injectable()
export class BillingService {
@@ -34,6 +46,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 ?? '',
@@ -58,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 },
@@ -74,22 +87,129 @@ export class BillingService {
if (subscription) {
const plan = subscription.plan
const effectiveLimits = this.getEffectiveLimits(subscription, plan)
try {
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. ${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 (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. ${effectiveLimits.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`,
meta: {
processedSmsLastMonth,
monthlyLimit: effectiveLimits.monthlyLimit,
},
sendEmail: true,
})
.catch(() => {})
}
}
} catch {}
return {
...subscription.toObject(),
usage: {
processedSmsToday,
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),
}
dailyLimit: effectiveLimits.dailyLimit,
monthlyLimit: effectiveLimits.monthlyLimit,
dailyRemaining:
effectiveLimits.dailyLimit === -1
? -1
: effectiveLimits.dailyLimit - processedSmsToday,
monthlyRemaining:
effectiveLimits.monthlyLimit === -1
? -1
: effectiveLimits.monthlyLimit - processedSmsLastMonth,
dailyUsagePercentage:
effectiveLimits.dailyLimit === -1
? 0
: Math.round(
(processedSmsToday / effectiveLimits.dailyLimit) * 100,
),
monthlyUsagePercentage:
effectiveLimits.monthlyLimit === -1
? 0
: 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 (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. ${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 (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. ${effectiveLimits.monthlyLimit - processedSmsLastMonth} messages remain this billing period. Upgrade to increase your monthly capacity.`,
meta: {
processedSmsLastMonth,
monthlyLimit: effectiveLimits.monthlyLimit,
},
sendEmail: true,
})
.catch(() => {})
}
}
} catch {}
return {
plan,
@@ -97,13 +217,29 @@ export class BillingService {
usage: {
processedSmsToday,
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),
}
dailyLimit: effectiveLimits.dailyLimit,
monthlyLimit: effectiveLimits.monthlyLimit,
dailyRemaining:
effectiveLimits.dailyLimit === -1
? -1
: effectiveLimits.dailyLimit - processedSmsToday,
monthlyRemaining:
effectiveLimits.monthlyLimit === -1
? -1
: effectiveLimits.monthlyLimit - processedSmsLastMonth,
dailyUsagePercentage:
effectiveLimits.dailyLimit === -1
? 0
: Math.round(
(processedSmsToday / effectiveLimits.dailyLimit) * 100,
),
monthlyUsagePercentage:
effectiveLimits.monthlyLimit === -1
? 0
: Math.round(
(processedSmsLastMonth / effectiveLimits.monthlyLimit) * 100,
),
},
}
}
@@ -131,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
@@ -140,7 +284,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 = {
@@ -170,22 +315,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) {
@@ -250,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 })
@@ -258,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({
@@ -393,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
@@ -403,7 +567,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 },
@@ -416,29 +580,37 @@ 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 = effectiveLimits.dailyLimit !== -1
const monthlyFinite = effectiveLimits.monthlyLimit !== -1
// exceeded checks
dailyExceeded =
dailyFinite && processedSmsToday + value > effectiveLimits.dailyLimit
monthlyExceeded =
monthlyFinite &&
processedSmsLastMonth + value > effectiveLimits.monthlyLimit
bulkExceeded =
effectiveLimits.bulkSendLimit !== -1 &&
value > effectiveLimits.bulkSendLimit
if (dailyExceeded) {
hasReachedLimit = true
message = `You have reached your daily limit, you only have ${plan.dailyLimit - processedSmsToday} remaining`
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.`
}
// 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 SMS limit reached you've used this billing period's allocation. Upgrade to continue sending immediately, or wait for the next billing period.`
}
// 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 exceeded — your plan allows up to ${effectiveLimits.bulkSendLimit} messages per batch. Split your send into smaller batches or upgrade your plan.`
}
}
@@ -453,23 +625,71 @@ 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,
}),
)
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: titleForEmail || 'Usage limit notice',
message,
meta: {
processedSmsToday,
processedSmsLastMonth,
attempted: value,
dailyLimit: effectiveLimits.dailyLimit,
monthlyLimit: effectiveLimits.monthlyLimit,
bulkSendLimit: effectiveLimits.bulkSendLimit,
},
sendEmail: true,
})
}
// if plan is not free and monthly limit is exceeded, give them 80% more monthly limit
if (
plan.name !== 'free' &&
monthlyExceeded &&
!dailyExceeded &&
!bulkExceeded
) {
const extendedMonthlyLimit = Math.floor(
effectiveLimits.monthlyLimit * 1.8,
)
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,
dailyLimit: effectiveLimits.dailyLimit,
dailyRemaining: effectiveLimits.dailyLimit - processedSmsToday,
monthlyRemaining:
effectiveLimits.monthlyLimit - processedSmsLastMonth,
bulkSendLimit: effectiveLimits.bulkSendLimit,
monthlyLimit: effectiveLimits.monthlyLimit,
},
HttpStatus.TOO_MANY_REQUESTS,
)
@@ -492,11 +712,20 @@ 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.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 },
@@ -513,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,
}
}

View File

@@ -0,0 +1,114 @@
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<BillingNotificationDocument>,
@InjectModel(User.name)
private readonly userModel: Model<UserDocument>,
) {}
@Process({ name: 'send', concurrency: 1 })
async handleSend(job: Job<{
notificationId: Types.ObjectId
userId: Types.ObjectId
type: string
title: string
message: string
meta: Record<string, any>
createdAt: Date
sendEmail?: boolean
}>) {
const payload = job.data
if (!payload?.sendEmail) {
return
}
const user = await this.userModel.findById(payload.userId)
if (!user?.email) {
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
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'
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 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':
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'
}
}
}

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)

View File

@@ -0,0 +1,87 @@
<html lang='en' xmlns:v='urn:schemas-microsoft-com:vml' xmlns:o='urn:schemas-microsoft-com:office:office'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta http-equiv='x-ua-compatible' content='ie=edge' />
<title>{{title}}{{brandName}}</title>
<style>
.preheader { display:none !important; visibility:hidden; opacity:0; color:transparent; height:0; width:0; overflow:hidden; mso-hide:all; }
@media screen and (max-width: 600px) { .container { width:100% !important; } .stack { display:block !important; width:100% !important; } .p-sm { padding:16px !important; } .text-center-sm { text-align:center !important; } .hide-sm { display:none !important; } }
</style>
<!--[if mso]>
<style type="text/css"> body, table, td, a { font-family: Helvetica, Arial, sans-serif !important; } </style>
<![endif]-->
</head>
<body style='margin:0; padding:0; background:#f7f9fc;'>
<div class='preheader'>Important account update from {{brandName}}</div>
<table role='presentation' cellpadding='0' cellspacing='0' border='0' width='100%' class='email-bg' style='background:#f7f9fc;'>
<tr>
<td align='center' style='padding:24px;'>
<table role='presentation' cellpadding='0' cellspacing='0' border='0' width='600' class='container' style='width:600px; max-width:600px;'>
<tr>
<td style='padding:12px 16px 0 16px;'>
<table role='presentation' width='100%' cellspacing='0' cellpadding='0' border='0'>
<tr>
<td class='stack' valign='middle' style='padding:8px 0;'>
<table role='presentation' cellspacing='0' cellpadding='0' border='0'>
<tr>
<td valign='middle' style='padding-right:10px;'>
<img src='https://textbee.dev/images/logo.png' alt='{{brandName}}' width='36' height='36' style='display:block; border:0; outline:none; text-decoration:none;' />
</td>
<td valign='middle' style='font:600 18px Arial, Helvetica, sans-serif; color:#EA580C;'>{{brandName}}</td>
</tr>
</table>
</td>
<td class='stack text-center-sm' valign='middle' align='right' style='padding:8px 0;'>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align='center' style='padding:16px 16px 0 16px;'>
<table role='presentation' width='100%' cellspacing='0' cellpadding='0' border='0' class='card' style='background:#ffffff; border-radius:10px;'>
<tr>
<td align='center' style='background:#F97316; border-radius:10px 10px 0 0; padding:28px 20px;'>
<div style='font:700 24px Arial, Helvetica, sans-serif; color:#ffffff;'>{{title}}</div>
</td>
</tr>
<tr>
<td class='p-sm' style='padding:28px; font:16px/1.6 Arial, Helvetica, sans-serif; color:#111827;'>
<div style='font:700 18px Arial, Helvetica, sans-serif; color:#111827; margin-bottom:8px;'>Hi {{name}},</div>
<p style='margin:0 0 16px 0;'>{{message}}</p>
<div style='text-align:center; padding:8px 0 2px;'>
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{ctaUrl}}" style="height:48px;v-text-anchor:middle;width:260px;" arcsize="10%" strokecolor="#EA580C" fillcolor="#F97316">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial, Helvetica, sans-serif;font-size:16px;font-weight:bold;">{{ctaLabel}}</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-- -->
<a href='{{ctaUrl}}' style='background:#F97316; border:1px solid #EA580C; border-radius:6px; color:#ffffff; display:inline-block; font:700 16px Arial, Helvetica, sans-serif; line-height:48px; text-align:center; text-decoration:none; width:260px;'>{{ctaLabel}}</a>
<!--<![endif]-->
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align='center' style='padding:16px;'>
<table role='presentation' width='100%' cellspacing='0' cellpadding='0' border='0'>
<tr>
<td align='center' style='font:12px/1.6 Arial, Helvetica, sans-serif; color:#6b7280;'>
<div>© 2025 {{brandName}}. All rights reserved.</div>
<div class='muted' style='margin-top:4px;'>Manage notifications in <a href='https://app.textbee.dev/dashboard/account/' style='color:#6b7280; text-decoration:underline;'>Account settings</a>.</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

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