mirror of
https://github.com/vernu/textbee.git
synced 2026-05-24 16:28:53 -04:00
Merge pull request #157 from vernu/billing-notification
billing notifications and limit overrides
This commit is contained in:
@@ -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
20
api/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
89
api/src/billing/billing-notifications.listener.ts
Normal file
89
api/src/billing/billing-notifications.listener.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
100
api/src/billing/billing-notifications.service.ts
Normal file
100
api/src/billing/billing-notifications.service.ts
Normal 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 }
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
api/src/billing/queue/billing-notifications.processor.ts
Normal file
114
api/src/billing/queue/billing-notifications.processor.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
58
api/src/billing/schemas/billing-notification.schema.ts
Normal file
58
api/src/billing/schemas/billing-notification.schema.ts
Normal 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)
|
||||
|
||||
|
||||
87
api/src/mail/templates/billing-notification.hbs
Normal file
87
api/src/mail/templates/billing-notification.hbs
Normal 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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user