mirror of
https://github.com/vernu/textbee.git
synced 2026-02-20 07:34:00 -05:00
new pricing and billing
This commit is contained in:
@@ -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
36
api/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
18
api/src/billing/billing.controller.spec.ts
Normal file
18
api/src/billing/billing.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
65
api/src/billing/billing.controller.ts
Normal file
65
api/src/billing/billing.controller.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
api/src/billing/billing.dto.ts
Normal file
36
api/src/billing/billing.dto.ts
Normal 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
|
||||
}
|
||||
27
api/src/billing/billing.module.ts
Normal file
27
api/src/billing/billing.module.ts
Normal 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 {}
|
||||
18
api/src/billing/billing.service.spec.ts
Normal file
18
api/src/billing/billing.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
400
api/src/billing/billing.service.ts
Normal file
400
api/src/billing/billing.service.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
33
api/src/billing/schemas/plan.schema.ts
Normal file
33
api/src/billing/schemas/plan.schema.ts
Normal 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)
|
||||
42
api/src/billing/schemas/subscription.schema.ts
Normal file
42
api/src/billing/schemas/subscription.schema.ts
Normal 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 })
|
||||
@@ -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],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
37
web/app/(app)/checkout/[planName]/page.tsx
Normal file
37
web/app/(app)/checkout/[planName]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
web/app/(landing-page)/(components)/pricing-section.tsx
Normal file
195
web/app/(landing-page)/(components)/pricing-section.tsx
Normal 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
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user