committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 942 additions and 5 deletions
-
1api/package.json
-
36api/pnpm-lock.yaml
-
2api/src/app.module.ts
-
18api/src/billing/billing.controller.spec.ts
-
65api/src/billing/billing.controller.ts
-
36api/src/billing/billing.dto.ts
-
27api/src/billing/billing.module.ts
-
18api/src/billing/billing.service.spec.ts
-
400api/src/billing/billing.service.ts
-
33api/src/billing/schemas/plan.schema.ts
-
42api/src/billing/schemas/subscription.schema.ts
-
4api/src/gateway/gateway.module.ts
-
23api/src/gateway/gateway.service.ts
-
3api/src/users/schemas/user.schema.ts
-
37web/app/(app)/checkout/[planName]/page.tsx
-
195web/app/(landing-page)/(components)/pricing-section.tsx
-
3web/app/(landing-page)/page.tsx
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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 {} |
||||
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
@ -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') |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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) |
||||
@ -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 }) |
||||
@ -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> |
||||
|
) |
||||
|
} |
||||
@ -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 |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue