You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

400 lines
11 KiB

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')
}
}
}