new pricing and billing

This commit is contained in:
isra el
2025-02-15 12:54:16 +03:00
parent d3e0bb5885
commit 014ca7cb52
17 changed files with 942 additions and 5 deletions

View File

@@ -30,6 +30,7 @@
"@nestjs/schedule": "^4.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.2.1",
"@polar-sh/sdk": "^0.19.2",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",

36
api/pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
'@nestjs/throttler':
specifier: ^6.2.1
version: 6.2.1(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)
'@polar-sh/sdk':
specifier: ^0.19.2
version: 0.19.2(zod@3.24.1)
axios:
specifier: ^1.7.7
version: 1.7.7
@@ -886,6 +889,11 @@ packages:
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@polar-sh/sdk@0.19.2':
resolution: {integrity: sha512-n1emRNmhcAzRfVAWBiVVCJ2krBSZ4wANVTRO7hCMchYCkxyV+kiRdjyvBdVG3JpRsS6SR7yBr2+CRJkuHPPeDg==}
peerDependencies:
zod: '>= 3'
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -1089,6 +1097,9 @@ packages:
resolution: {integrity: sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==}
engines: {node: '>=16.0.0'}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@tootallnate/once@1.1.2':
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
@@ -2168,6 +2179,9 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fast-xml-parser@4.4.1:
resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==}
hasBin: true
@@ -3698,6 +3712,9 @@ packages:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -4195,6 +4212,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@3.24.1:
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
snapshots:
'@ampproject/remapping@2.3.0':
@@ -5475,6 +5495,11 @@ snapshots:
'@pkgr/core@0.1.1': {}
'@polar-sh/sdk@0.19.2(zod@3.24.1)':
dependencies:
standardwebhooks: 1.0.0
zod: 3.24.1
'@protobufjs/aspromise@1.1.2':
optional: true
@@ -5827,6 +5852,8 @@ snapshots:
tslib: 2.8.0
optional: true
'@stablelib/base64@1.0.1': {}
'@tootallnate/once@1.1.2': {}
'@tootallnate/once@2.0.0':
@@ -7095,6 +7122,8 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fast-xml-parser@4.4.1:
dependencies:
strnum: 1.0.5
@@ -8996,6 +9025,11 @@ snapshots:
dependencies:
escape-string-regexp: 2.0.0
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
statuses@2.0.1: {}
stream-events@1.0.5:
@@ -9502,3 +9536,5 @@ snapshots:
yn@3.1.1: {}
yocto-queue@0.1.0: {}
zod@3.24.1: {}

View File

@@ -8,6 +8,7 @@ import { APP_GUARD } from '@nestjs/core/constants'
import { WebhookModule } from './webhook/webhook.module'
import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard'
import { ScheduleModule } from '@nestjs/schedule'
import { BillingModule } from './billing/billing.module';
@Module({
imports: [
@@ -23,6 +24,7 @@ import { ScheduleModule } from '@nestjs/schedule'
UsersModule,
GatewayModule,
WebhookModule,
BillingModule,
],
controllers: [],
providers: [

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BillingController } from './billing.controller';
describe('BillingController', () => {
let controller: BillingController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [BillingController],
}).compile();
controller = module.get<BillingController>(BillingController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,65 @@
import { Controller, Post, Body, Get, UseGuards, Request } from '@nestjs/common'
import { BillingService } from './billing.service'
import { AuthGuard } from 'src/auth/guards/auth.guard'
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'
import {
CheckoutInputDTO,
CheckoutResponseDTO,
PlansResponseDTO,
} from './billing.dto'
@ApiTags('billing')
@ApiBearerAuth()
@Controller('billing')
export class BillingController {
constructor(private billingService: BillingService) {}
@Get('plans')
async getPlans(): Promise<PlansResponseDTO> {
return this.billingService.getPlans()
}
@Post('checkout')
@UseGuards(AuthGuard)
async getCheckoutUrl(
@Body() payload: CheckoutInputDTO,
@Request() req: any,
): Promise<CheckoutResponseDTO> {
return this.billingService.getCheckoutUrl({
user: req.user,
payload,
req,
})
}
@Post('webhook/polar')
async handlePolarWebhook(@Body() data: any, @Request() req: any) {
const payload = await this.billingService.validatePolarWebhookPayload(
data,
req.headers,
)
// Handle Polar.sh webhook events
switch (payload.type) {
case 'subscription.created':
await this.billingService.switchPlan({
userId: payload.data.userId,
newPlanName: payload.data?.product?.name || 'pro',
newPlanPolarProductId: payload.data?.product?.id,
})
break
// @ts-ignore
case 'subscription.cancelled':
await this.billingService.switchPlan({
// @ts-ignore
userId: payload?.data?.userId,
newPlanName: 'free',
})
break
default:
console.log('Unhandled event type:', payload.type)
break
}
}
}

View File

@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger'
export class PlanDTO {
@ApiProperty({ type: String })
name: string
@ApiProperty({ type: Number })
monthlyPrice: number
@ApiProperty({ type: Number })
yearlyPrice?: number
@ApiProperty({ type: String })
polarProductId: string
@ApiProperty({ type: Boolean })
isActive: boolean
}
export class PlansResponseDTO extends Array<PlanDTO> {}
export class CheckoutInputDTO {
@ApiProperty({ type: String, required: true })
planName: string
@ApiProperty({ type: String })
discountId?: string
@ApiProperty({ type: Boolean })
isYearly?: boolean
}
export class CheckoutResponseDTO {
@ApiProperty({ type: String })
redirectUrl: string
}

View File

@@ -0,0 +1,27 @@
import { Module, forwardRef } from '@nestjs/common'
import { BillingController } from './billing.controller'
import { BillingService } from './billing.service'
import { PlanSchema } from './schemas/plan.schema'
import { SubscriptionSchema } from './schemas/subscription.schema'
import { Plan } from './schemas/plan.schema'
import { Subscription } from './schemas/subscription.schema'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from 'src/auth/auth.module'
import { UsersModule } from 'src/users/users.module'
import { GatewayModule } from 'src/gateway/gateway.module'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Plan.name, schema: PlanSchema },
{ name: Subscription.name, schema: SubscriptionSchema },
]),
AuthModule,
UsersModule,
forwardRef(() => GatewayModule),
],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BillingService } from './billing.service';
describe('BillingService', () => {
let service: BillingService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [BillingService],
}).compile();
service = module.get<BillingService>(BillingService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,400 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { Plan, PlanDocument } from './schemas/plan.schema'
import {
Subscription,
SubscriptionDocument,
} from './schemas/subscription.schema'
import { Polar } from '@polar-sh/sdk'
import { User, UserDocument } from 'src/users/schemas/user.schema'
import { CheckoutResponseDTO, PlanDTO } from './billing.dto'
import { SMSDocument } from 'src/gateway/schemas/sms.schema'
import { SMS } from 'src/gateway/schemas/sms.schema'
import { validateEvent } from '@polar-sh/sdk/webhooks'
@Injectable()
export class BillingService {
private polarApi
constructor(
@InjectModel(Plan.name) private planModel: Model<PlanDocument>,
@InjectModel(Subscription.name)
private subscriptionModel: Model<SubscriptionDocument>,
@InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(SMS.name) private smsModel: Model<SMSDocument>,
) {
this.initializePlans()
this.polarApi = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN ?? '',
server:
process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox',
})
}
private async initializePlans() {
const plans = await this.planModel.find()
if (plans.length === 0) {
await this.planModel.create([
{
name: 'free',
dailyLimit: 50,
monthlyLimit: 1000,
bulkSendLimit: 50,
monthlyPrice: 0,
yearlyPrice: 0,
},
{
name: 'pro',
dailyLimit: -1, // -1 means unlimited
monthlyLimit: 5000,
bulkSendLimit: -1,
monthlyPrice: 690, // $6.90
yearlyPrice: 6900, // $69.00
},
{
name: 'custom',
dailyLimit: -1,
monthlyLimit: -1,
bulkSendLimit: -1,
monthlyPrice: 0, // Custom pricing
yearlyPrice: 0, // Custom pricing
},
])
}
}
async getPlans(): Promise<PlanDTO[]> {
return this.planModel.find({
isActive: true,
})
}
async getCheckoutUrl({
user,
payload,
req,
}: {
user: any
payload: any
req: any
}): Promise<CheckoutResponseDTO> {
const isYearly = payload.isYearly
const selectedPlan = await this.planModel.findOne({
name: payload.planName,
})
if (!selectedPlan?.polarProductId) {
throw new Error('Plan cannot be purchased')
}
// const product = await this.polarApi.products.get(selectedPlan.polarProductId)
const discountId =
payload.discountId ?? '48f62ff7-3cd8-46ec-8ca7-2e570dc9c522'
try {
const checkoutOptions: any = {
productId: selectedPlan.polarProductId,
// productPriceId: isYearly
// ? selectedPlan.yearlyPolarProductId
// : selectedPlan.monthlyPolarProductId,
successUrl: `${process.env.FRONTEND_URL}/checkout-success?checkout_id={CHECKOUT_ID}`,
cancelUrl: `${process.env.FRONTEND_URL}/checkout-cancel?checkout_id={CHECKOUT_ID}`,
customerEmail: user.email,
customerName: user.name,
customerIpAddress: req.ip,
metadata: {
userId: user._id?.toString(),
},
}
const discount = await this.polarApi.discounts.get({
id: discountId,
})
if (discount) {
checkoutOptions.discountId = discount.id
}
const checkout =
await this.polarApi.checkouts.custom.create(checkoutOptions)
console.log(checkout)
return { redirectUrl: checkout.url }
} catch (error) {
console.error(error)
throw new Error('Failed to create checkout')
}
}
async getActiveSubscription(userId: string) {
const user = await this.userModel.findById(userId)
const plans = await this.planModel.find()
const customPlan = plans.find((plan) => plan.name === 'custom')
const proPlan = plans.find((plan) => plan.name === 'pro')
const freePlan = plans.find((plan) => plan.name === 'free')
const customPlanSubscription = await this.subscriptionModel.findOne({
user: userId,
plan: customPlan._id,
isActive: true,
})
if (customPlanSubscription) {
return customPlanSubscription
}
const proPlanSubscription = await this.subscriptionModel.findOne({
user: userId,
plan: proPlan._id,
isActive: true,
})
if (proPlanSubscription) {
return proPlanSubscription
}
const freePlanSubscription = await this.subscriptionModel.findOne({
user: userId,
plan: freePlan._id,
isActive: true,
})
if (freePlanSubscription) {
return freePlanSubscription
}
// create a new free plan subscription
const newFreePlanSubscription = await this.subscriptionModel.create({
user: userId,
plan: freePlan._id,
isActive: true,
startDate: new Date(),
})
return newFreePlanSubscription
}
async getUserLimits(userId: string) {
const subscription = await this.subscriptionModel
.findOne({ user: userId, isActive: true })
.populate('plan')
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,
}
}
// 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,
}
}
async switchPlan({
userId,
newPlanName,
newPlanPolarProductId,
}: {
userId: string
newPlanName?: string
newPlanPolarProductId?: string
}) {
// switch the subscription to the new one
// deactivate the current active subscription
// activate the new subscription if it exists or create a new one
// get the plan from the polarProductId
let plan: PlanDocument
if (newPlanPolarProductId) {
plan = await this.planModel.findOne({
polarProductId: newPlanPolarProductId,
})
} else if (newPlanName) {
plan = await this.planModel.findOne({ name: newPlanName })
}
if (!plan) {
throw new Error('Plan not found')
}
// if any of the subscriptions that are not the new plan are active, deactivate them
await this.subscriptionModel.updateMany(
{ user: userId, plan: { $ne: plan._id }, isActive: true },
{ isActive: false, endDate: new Date() },
)
// create or update the new subscription
await this.subscriptionModel.updateOne(
{ user: userId, plan: plan._id },
{ isActive: true },
{ upsert: true },
)
}
async canPerformAction(
userId: string,
action: 'send_sms' | 'receive_sms' | 'bulk_send_sms',
value: number,
) {
// TODO: temporary allow all requests until march 15 2025
if (new Date() < new Date('2025-03-15')) {
return true
}
const user = await this.userModel.findById(userId)
if (user.isBanned) {
throw new HttpException(
{
message: 'Sorry, we cannot process your request at the moment',
},
HttpStatus.BAD_REQUEST,
)
}
if (user.emailVerifiedAt === null) {
// throw new HttpException(
// {
// message: 'Please verify your email to continue',
// },
// HttpStatus.BAD_REQUEST,
// )
}
let plan: PlanDocument
const subscription = await this.subscriptionModel.findOne({
user: userId,
isActive: true,
})
if (!subscription) {
plan = await this.planModel.findOne({ name: 'free' })
} else {
plan = await this.planModel.findById(subscription.plan)
}
if (plan.name === 'custom') {
// TODO: for now custom plans are unlimited
return true
}
let hasReachedLimit = false
let message = ''
const processedSmsToday = await this.smsModel.countDocuments({
'device.user': userId,
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) },
})
const processedSmsLastMonth = await this.smsModel.countDocuments({
'device.user': userId,
createdAt: {
$gte: new Date(new Date().setMonth(new Date().getMonth() - 1)),
},
})
if (['send_sms', 'receive_sms', 'bulk_send_sms'].includes(action)) {
// check daily limit
if (
plan.dailyLimit !== -1 &&
processedSmsToday + value > plan.dailyLimit
) {
hasReachedLimit = true
message = `You have reached your daily limit, you only have ${plan.dailyLimit - processedSmsToday} remaining`
}
// check monthly limit
if (
plan.monthlyLimit !== -1 &&
processedSmsLastMonth + value > plan.monthlyLimit
) {
hasReachedLimit = true
message = `You have reached your monthly limit, you only have ${plan.monthlyLimit - processedSmsLastMonth} remaining`
}
// check bulk send limit
if (plan.bulkSendLimit !== -1 && value > plan.bulkSendLimit) {
hasReachedLimit = true
message = `You can only send ${plan.bulkSendLimit} sms at a time`
}
}
if (hasReachedLimit) {
throw new HttpException(
{
message: message,
hasReachedLimit: true,
dailyLimit: plan.dailyLimit,
dailyRemaining: plan.dailyLimit - processedSmsToday,
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
bulkSendLimit: plan.bulkSendLimit,
monthlyLimit: plan.monthlyLimit,
},
HttpStatus.BAD_REQUEST,
)
}
return true
}
async getUsage(userId: string) {
const subscription = await this.subscriptionModel.findOne({
user: userId,
isActive: true,
})
const plan = await this.planModel.findById(subscription.plan)
const processedSmsToday = await this.smsModel.countDocuments({
'device.user': userId,
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) },
})
const processedSmsLastMonth = await this.smsModel.countDocuments({
'device.user': userId,
createdAt: {
$gte: new Date(new Date().setMonth(new Date().getMonth() - 1)),
},
})
return {
processedSmsToday,
processedSmsLastMonth,
dailyLimit: plan.dailyLimit,
monthlyLimit: plan.monthlyLimit,
bulkSendLimit: plan.bulkSendLimit,
dailyRemaining: plan.dailyLimit - processedSmsToday,
monthlyRemaining: plan.monthlyLimit - processedSmsLastMonth,
}
}
async validatePolarWebhookPayload(payload: any, headers: any) {
const webhookHeaders = {
'webhook-id': headers['webhook-id'] ?? '',
'webhook-timestamp': headers['webhook-timestamp'] ?? '',
'webhook-signature': headers['webhook-signature'] ?? '',
}
try {
const webhookPayload = validateEvent(
payload,
webhookHeaders,
process.env.POLAR_WEBHOOK_SECRET,
)
return webhookPayload
} catch (error) {
throw new Error('Invalid webhook payload')
}
}
}

View File

@@ -0,0 +1,33 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type PlanDocument = Plan & Document
@Schema({ timestamps: true })
export class Plan {
@Prop({ required: true, unique: true })
name: string // free, pro, custom
@Prop({ required: true })
dailyLimit: number
@Prop({ required: true })
monthlyLimit: number
@Prop({ required: true })
bulkSendLimit: number
@Prop({ required: true })
monthlyPrice: number // in cents
@Prop({})
yearlyPrice: number // in cents
@Prop({ type: String, unique: true })
polarProductId?: string
@Prop({ type: Boolean, default: true })
isActive: boolean
}
export const PlanSchema = SchemaFactory.createForClass(Plan)

View File

@@ -0,0 +1,42 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document, Types } from 'mongoose'
import { User } from '../../users/schemas/user.schema'
import { Plan } from './plan.schema'
export type SubscriptionDocument = Subscription & Document
@Schema({ timestamps: true })
export class Subscription {
@Prop({ type: Types.ObjectId, ref: User.name, required: true })
user: User
@Prop({ type: Types.ObjectId, ref: Plan.name, required: true })
plan: Plan
// @Prop()
// polarSubscriptionId?: string
@Prop({ type: Date })
startDate: Date
@Prop({ type: Date })
endDate: Date
@Prop({ type: Boolean, default: true })
isActive: boolean
// Custom limits for custom plans
@Prop({ type: Number })
customDailyLimit?: number
@Prop({ type: Number })
customMonthlyLimit?: number
@Prop({ type: Number })
customBulkSendLimit?: number
}
export const SubscriptionSchema = SchemaFactory.createForClass(Subscription)
// a user can only have one active subscription at a time
SubscriptionSchema.index({ user: 1, isActive: 1 }, { unique: true })

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'
import { forwardRef, Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { Device, DeviceSchema } from './schemas/device.schema'
import { GatewayController } from './gateway.controller'
@@ -8,6 +8,7 @@ import { UsersModule } from '../users/users.module'
import { SMS, SMSSchema } from './schemas/sms.schema'
import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema'
import { WebhookModule } from 'src/webhook/webhook.module'
import { BillingModule } from 'src/billing/billing.module'
@Module({
imports: [
@@ -28,6 +29,7 @@ import { WebhookModule } from 'src/webhook/webhook.module'
AuthModule,
UsersModule,
WebhookModule,
forwardRef(() => BillingModule),
],
controllers: [GatewayController],
providers: [GatewayService],

View File

@@ -21,6 +21,7 @@ import {
} from 'firebase-admin/lib/messaging/messaging-api'
import { WebhookEvent } from 'src/webhook/webhook-event.enum'
import { WebhookService } from 'src/webhook/webhook.service'
import { BillingService } from 'src/billing/billing.service'
@Injectable()
export class GatewayService {
constructor(
@@ -29,6 +30,7 @@ export class GatewayService {
@InjectModel(SMSBatch.name) private smsBatchModel: Model<SMSBatch>,
private authService: AuthService,
private webhookService: WebhookService,
private billingService: BillingService,
) {}
async registerDevice(
@@ -113,6 +115,12 @@ export class GatewayService {
const message = smsData.message || smsData.smsBody
const recipients = smsData.recipients || smsData.receivers
await this.billingService.canPerformAction(
device.user.toString(),
'send_sms',
recipients.length,
)
if (!message) {
throw new HttpException(
{
@@ -241,6 +249,12 @@ export class GatewayService {
)
}
await this.billingService.canPerformAction(
device.user.toString(),
'bulk_send_sms',
body.messages.map((m) => m.recipients).flat().length,
)
if (
!Array.isArray(body.messages) ||
body.messages.length === 0 ||
@@ -377,6 +391,12 @@ export class GatewayService {
)
}
await this.billingService.canPerformAction(
device.user.toString(),
'receive_sms',
1,
)
if (
(!dto.receivedAt && !dto.receivedAtInMillis) ||
!dto.sender ||
@@ -414,9 +434,10 @@ export class GatewayService {
console.log(e)
})
this.webhookService.deliverNotification({
sms,
user: device.user,
this.webhookService
.deliverNotification({
sms,
user: device.user,
event: WebhookEvent.MESSAGE_RECEIVED,
})
.catch((e) => {

View File

@@ -34,6 +34,9 @@ export class User {
@Prop({ type: Date })
emailVerifiedAt: Date
@Prop({ type: Boolean, default: false })
isBanned: boolean
}
export const UserSchema = SchemaFactory.createForClass(User)

View File

@@ -0,0 +1,37 @@
'use client'
import { useState, useEffect } from 'react'
import httpBrowserClient from '@/lib/httpBrowserClient'
export default function CheckoutPage({ params }) {
const [error, setError] = useState<string | null>(null)
const planName = params.planName as string
useEffect(() => {
const initiateCheckout = async () => {
try {
const response = await httpBrowserClient.post('/billing/checkout', {
planName,
})
window.location.href = response.data?.redirectUrl
} catch (error) {
setError('Failed to create checkout session. Please try again.')
console.error(error)
}
}
initiateCheckout()
}, [planName])
if (error) {
return <div className='text-red-500'>{error}</div>
}
return (
<div className='flex justify-center items-center min-h-[50vh]'>
processing...
</div>
)
}

View File

@@ -0,0 +1,195 @@
'use client'
import { Button } from '@/components/ui/button'
import { Check } from 'lucide-react'
import Link from 'next/link'
const PricingSection = () => {
return (
<section
id='pricing'
className='py-16 bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-gray-950'
>
<div className='container px-4 mx-auto'>
<div className='max-w-2xl mx-auto mb-12 text-center'>
<h2 className='text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl'>
Pricing
</h2>
<p className='mt-3 text-base text-gray-600 dark:text-gray-400'>
Choose the perfect plan for your messaging needs
</p>
</div>
<div className='grid gap-6 lg:grid-cols-3'>
{/* Free Plan */}
<div className='flex flex-col p-5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow'>
<h3 className='text-xl font-bold text-gray-900 dark:text-white'>
Free
</h3>
<p className='mt-3 text-sm text-gray-600 dark:text-gray-400'>
Perfect for getting started
</p>
<div className='my-6'>
<span className='text-3xl font-bold text-gray-900 dark:text-white'>
$0
</span>
<span className='text-gray-600 dark:text-gray-400'>/month</span>
</div>
<ul className='mb-6 space-y-3 flex-1'>
<Feature text='Send and receive SMS Messages' />
<Feature text='Register 1 active device' />
<Feature text='Max 50 messages per day' />
<Feature text='Up to 500 messages per month' />
<Feature text='Up to 50 recipients in bulk' />
<Feature text='Webhook notifications' />
<Feature text='Basic support' />
</ul>
<Button asChild className='w-full' variant='outline'>
<Link href='/dashboard?selectedPlan=free'>Get Started</Link>
</Button>
</div>
{/* Pro Plan */}
<div className='flex flex-col p-5 bg-slate-800 dark:bg-gray-800/60 text-white rounded-lg border border-gray-800 dark:border-gray-600 shadow-lg scale-105 hover:scale-105 transition-transform'>
<div className='inline-block px-3 py-1 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-xs font-semibold mb-3'>
MOST POPULAR
</div>
<h3 className='text-xl font-bold'>Pro</h3>
<p className='mt-3 text-sm text-gray-300'>
Ideal for most use-cases
</p>
<div className='my-6'>
<div className='grid grid-cols-2 gap-2'>
{/* Monthly pricing */}
<div className='space-y-2'>
<div className='flex items-baseline'>
<span className='text-xs text-gray-400 uppercase'>
Monthly
</span>
</div>
<div>
<div className='space-y-1'>
<div className='text-lg text-gray-400 line-through'>
$9.90
</div>
<div className='flex items-baseline gap-1'>
<span className='text-3xl font-bold'>$6.90</span>
<span className='text-gray-300'>/month</span>
</div>
</div>
<span className='mt-1 inline-block bg-green-500/10 text-green-400 text-xs px-2 py-0.5 rounded-full border border-green-500/20'>
Save 30%
</span>
</div>
</div>
{/* Yearly pricing */}
<div className='space-y-2 border-l border-gray-800 pl-2'>
<div className='flex items-baseline gap-2'>
<span className='text-xs text-gray-400 uppercase'>
Yearly
</span>
<span className='text-xs text-green-400'>
(2 months free)
</span>
</div>
<div>
<div className='space-y-1'>
<div className='text-lg text-gray-400 line-through'>
$99
</div>
<div className='flex items-baseline gap-1'>
<span className='text-3xl font-bold'>$69</span>
<span className='text-gray-300'>/year</span>
</div>
</div>
<span className='mt-1 inline-block bg-green-500/10 text-green-400 text-xs px-2 py-0.5 rounded-full border border-green-500/20'>
Save 42%
</span>
</div>
</div>
</div>
</div>
<ul className='mb-6 space-y-3 flex-1'>
<Feature text='Everything in Free' light />
<Feature text='Register upto 5 active devices' light />
<Feature
text='Unlimited daily messages (within monthly quota)'
light
/>
<Feature text='Up to 5,000 messages per month' light />
<Feature text='No bulk SMS recipient limits' light />
<Feature text='Priority support' light />
</ul>
<Button
asChild
className='w-full bg-white text-black hover:bg-gray-100'
>
<Link href='/dashboard?selectedPlan=pro'>Upgrade to Pro</Link>
</Button>
</div>
{/* Custom Plan */}
<div className='flex flex-col p-5 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow'>
<h3 className='text-xl font-bold text-gray-900 dark:text-white'>
Custom
</h3>
<p className='mt-3 text-sm text-gray-600 dark:text-gray-400'>
For more specific needs or custom integrations
</p>
<div className='my-6'>
<span className='text-3xl font-bold text-gray-900 dark:text-white'>
Custom
</span>
<span className='text-gray-600 dark:text-gray-400'> pricing</span>
</div>
<ul className='mb-6 space-y-3 flex-1'>
<Feature text='Custom message limits' />
<Feature text='Custom bulk limits' />
<Feature text='Custom integrations' />
<Feature text='SLA agreement' />
<Feature text='Dedicated support' />
</ul>
<Button asChild className='w-full' variant='outline'>
<Link href='mailto:sales@textbee.dev?subject=Interested%20in%20TextBee%20Custom%20Plan'>
Contact Sales
</Link>
</Button>
</div>
</div>
</div>
</section>
)
}
const Feature = ({
text,
light = false,
}: {
text: string
light?: boolean
}) => (
<li className='flex items-center'>
<Check
className={`h-4 w-4 ${
light ? 'text-green-400' : 'text-green-500 dark:text-green-400'
} mr-2`}
/>
<span
className={`text-sm ${
light ? 'text-gray-300' : 'text-gray-600 dark:text-gray-300'
}`}
>
{text}
</span>
</li>
)
export default PricingSection

View File

@@ -5,7 +5,7 @@ import HowItWorksSection from '@/app/(landing-page)/(components)/how-it-works-se
import CustomizationSection from '@/app/(landing-page)/(components)/customization-section'
import SupportProjectSection from '@/app/(landing-page)/(components)/support-project-section'
import CodeSnippetSection from '@/app/(landing-page)/(components)/code-snippet-section'
import PricingSection from '@/app/(landing-page)/(components)/pricing-section'
export default function LandingPage() {
return (
<div className='flex min-h-screen flex-col'>
@@ -14,6 +14,7 @@ export default function LandingPage() {
<FeaturesSection />
<HowItWorksSection />
<DownloadAppSection />
<PricingSection />
<CustomizationSection />
<CodeSnippetSection />
<SupportProjectSection />