diff --git a/api/package.json b/api/package.json index 463fe3d..f82d744 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index f50b029..574ec98 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -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: {} diff --git a/api/src/app.module.ts b/api/src/app.module.ts index fce66fe..06b78ce 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -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: [ diff --git a/api/src/billing/billing.controller.spec.ts b/api/src/billing/billing.controller.spec.ts new file mode 100644 index 0000000..4aa4b8e --- /dev/null +++ b/api/src/billing/billing.controller.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/api/src/billing/billing.controller.ts b/api/src/billing/billing.controller.ts new file mode 100644 index 0000000..4e12817 --- /dev/null +++ b/api/src/billing/billing.controller.ts @@ -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 { + return this.billingService.getPlans() + } + + @Post('checkout') + @UseGuards(AuthGuard) + async getCheckoutUrl( + @Body() payload: CheckoutInputDTO, + @Request() req: any, + ): Promise { + 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 + } + } +} diff --git a/api/src/billing/billing.dto.ts b/api/src/billing/billing.dto.ts new file mode 100644 index 0000000..13218bf --- /dev/null +++ b/api/src/billing/billing.dto.ts @@ -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 {} + +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 +} diff --git a/api/src/billing/billing.module.ts b/api/src/billing/billing.module.ts new file mode 100644 index 0000000..0b37d28 --- /dev/null +++ b/api/src/billing/billing.module.ts @@ -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 {} diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts new file mode 100644 index 0000000..ed64d4e --- /dev/null +++ b/api/src/billing/billing.service.spec.ts @@ -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); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts new file mode 100644 index 0000000..7b8224b --- /dev/null +++ b/api/src/billing/billing.service.ts @@ -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, + @InjectModel(Subscription.name) + private subscriptionModel: Model, + @InjectModel(User.name) private userModel: Model, + @InjectModel(SMS.name) private smsModel: Model, + ) { + 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 { + return this.planModel.find({ + isActive: true, + }) + } + + async getCheckoutUrl({ + user, + payload, + req, + }: { + user: any + payload: any + req: any + }): Promise { + 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') + } + } +} diff --git a/api/src/billing/schemas/plan.schema.ts b/api/src/billing/schemas/plan.schema.ts new file mode 100644 index 0000000..584f970 --- /dev/null +++ b/api/src/billing/schemas/plan.schema.ts @@ -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) diff --git a/api/src/billing/schemas/subscription.schema.ts b/api/src/billing/schemas/subscription.schema.ts new file mode 100644 index 0000000..1318e29 --- /dev/null +++ b/api/src/billing/schemas/subscription.schema.ts @@ -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 }) diff --git a/api/src/gateway/gateway.module.ts b/api/src/gateway/gateway.module.ts index a3caba3..8eb3d41 100644 --- a/api/src/gateway/gateway.module.ts +++ b/api/src/gateway/gateway.module.ts @@ -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], diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 8e0d039..0091c28 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -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, 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) => { diff --git a/api/src/users/schemas/user.schema.ts b/api/src/users/schemas/user.schema.ts index 0b767be..430c929 100644 --- a/api/src/users/schemas/user.schema.ts +++ b/api/src/users/schemas/user.schema.ts @@ -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) diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx new file mode 100644 index 0000000..e25bddc --- /dev/null +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -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(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
{error}
+ } + + return ( +
+ processing... +
+ ) +} diff --git a/web/app/(landing-page)/(components)/pricing-section.tsx b/web/app/(landing-page)/(components)/pricing-section.tsx new file mode 100644 index 0000000..082ba7f --- /dev/null +++ b/web/app/(landing-page)/(components)/pricing-section.tsx @@ -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 ( +
+
+
+

+ Pricing +

+

+ Choose the perfect plan for your messaging needs +

+
+ +
+ {/* Free Plan */} +
+

+ Free +

+

+ Perfect for getting started +

+
+ + $0 + + /month +
+ +
    + + + + + + + +
+ + +
+ + {/* Pro Plan */} +
+
+ MOST POPULAR +
+

Pro

+

+ Ideal for most use-cases +

+ +
+
+ {/* Monthly pricing */} +
+
+ + Monthly + +
+
+
+
+ $9.90 +
+
+ $6.90 + /month +
+
+ + Save 30% + +
+
+ + {/* Yearly pricing */} +
+
+ + Yearly + + + (2 months free) + +
+
+
+
+ $99 +
+
+ $69 + /year +
+
+ + Save 42% + +
+
+
+
+ +
    + + + + + + +
+ + +
+ + {/* Custom Plan */} +
+

+ Custom +

+

+ For more specific needs or custom integrations +

+
+ + Custom + + pricing +
+ +
    + + + + + +
+ + +
+
+
+
+ ) +} + +const Feature = ({ + text, + light = false, +}: { + text: string + light?: boolean +}) => ( +
  • + + + {text} + +
  • +) + +export default PricingSection diff --git a/web/app/(landing-page)/page.tsx b/web/app/(landing-page)/page.tsx index 3008806..72b33fb 100644 --- a/web/app/(landing-page)/page.tsx +++ b/web/app/(landing-page)/page.tsx @@ -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 (
    @@ -14,6 +14,7 @@ export default function LandingPage() { +