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

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