chore(api): update polar sdk and improve billing and checkout logic

This commit is contained in:
isra el
2025-02-21 20:21:36 +03:00
parent a199304e30
commit 992c260e1a
8 changed files with 89 additions and 66 deletions

View File

@@ -30,7 +30,7 @@
"@nestjs/schedule": "^4.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.2.1",
"@polar-sh/sdk": "^0.19.2",
"@polar-sh/sdk": "^0.26.1",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",

10
api/pnpm-lock.yaml generated
View File

@@ -39,8 +39,8 @@ importers:
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)(reflect-metadata@0.2.2)
'@polar-sh/sdk':
specifier: ^0.19.2
version: 0.19.2(zod@3.24.1)
specifier: ^0.26.1
version: 0.26.1(zod@3.24.1)
axios:
specifier: ^1.7.7
version: 1.7.7
@@ -892,8 +892,8 @@ 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==}
'@polar-sh/sdk@0.26.1':
resolution: {integrity: sha512-OEaxiNJaxpeNi7LANHR5S71BAyORk6W0lwkfHcrGyMGS9VDdgXnZjB8QZ3tFSXbQvt3yZdHShX6pPC8xOxNvFw==}
peerDependencies:
zod: '>= 3'
@@ -5505,7 +5505,7 @@ snapshots:
'@pkgr/core@0.1.1': {}
'@polar-sh/sdk@0.19.2(zod@3.24.1)':
'@polar-sh/sdk@0.26.1(zod@3.24.1)':
dependencies:
standardwebhooks: 1.0.0
zod: 3.24.1

View File

@@ -57,8 +57,14 @@ export class BillingController {
console.log(payload)
await this.billingService.switchPlan({
userId: payload.data?.metadata?.userId as string,
newPlanName: payload.data?.product?.name?.split(' ')[payload.data?.product?.name?.length - 1] || 'pro',
// TODO: remove this after more plans are added
newPlanName: 'pro',
newPlanPolarProductId: payload.data?.product?.id,
currentPeriodStart: payload.data?.currentPeriodStart,
currentPeriodEnd: payload.data?.currentPeriodEnd,
status: payload.data?.status,
subscriptionStartDate: payload.data?.createdAt,
subscriptionEndDate: payload.data?.canceledAt,
})
break

View File

@@ -11,7 +11,13 @@ export class PlanDTO {
yearlyPrice?: number
@ApiProperty({ type: String })
polarProductId: string
polarProductId?: string
@ApiProperty({ type: String })
polarMonthlyProductId?: string
@ApiProperty({ type: String })
polarYearlyProductId?: string
@ApiProperty({ type: Boolean })
isActive: boolean

View File

@@ -12,7 +12,10 @@ 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'
import { PolarWebhookPayload, PolarWebhookPayloadDocument } from './schemas/polar-webhook-payload.schema'
import {
PolarWebhookPayload,
PolarWebhookPayloadDocument,
} from './schemas/polar-webhook-payload.schema'
@Injectable()
export class BillingService {
@@ -27,7 +30,6 @@ export class BillingService {
@InjectModel(PolarWebhookPayload.name)
private polarWebhookPayloadModel: Model<PolarWebhookPayloadDocument>,
) {
this.initializePlans()
this.polarApi = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN ?? '',
server:
@@ -35,38 +37,6 @@ export class BillingService {
})
}
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,
@@ -79,8 +49,6 @@ export class BillingService {
isActive: true,
})
let plan = null
if (!subscription) {
@@ -107,7 +75,10 @@ export class BillingService {
name: payload.planName,
})
if (!selectedPlan?.polarProductId) {
if (
!selectedPlan?.polarMonthlyProductId &&
!selectedPlan?.polarYearlyProductId
) {
throw new Error('Plan cannot be purchased')
}
@@ -118,10 +89,11 @@ export class BillingService {
try {
const checkoutOptions: any = {
productId: selectedPlan.polarProductId,
// productPriceId: isYearly
// ? selectedPlan.yearlyPolarProductId
// : selectedPlan.monthlyPolarProductId,
// productId: selectedPlan.polarProductId, // deprecated
products: [
selectedPlan.polarMonthlyProductId,
selectedPlan.polarYearlyProductId,
],
successUrl: `${process.env.FRONTEND_URL}/dashboard?checkout-success=1&checkout_id={CHECKOUT_ID}`,
cancelUrl: `${process.env.FRONTEND_URL}/dashboard?checkout-cancel=1&checkout_id={CHECKOUT_ID}`,
customerEmail: user.email,
@@ -138,8 +110,7 @@ export class BillingService {
checkoutOptions.discountId = discount.id
}
const checkout =
await this.polarApi.checkouts.custom.create(checkoutOptions)
const checkout = await this.polarApi.checkouts.create(checkoutOptions)
console.log(checkout)
return { redirectUrl: checkout.url }
} catch (error) {
@@ -226,20 +197,35 @@ export class BillingService {
userId,
newPlanName,
newPlanPolarProductId,
currentPeriodStart,
currentPeriodEnd,
subscriptionStartDate,
subscriptionEndDate,
status,
}: {
userId: string
newPlanName?: string
newPlanPolarProductId?: string
createdAt?: Date
currentPeriodStart?: Date
currentPeriodEnd?: Date
subscriptionStartDate?: Date
subscriptionEndDate?: Date
status?: string
}) {
console.log(`Switching plan for user: ${userId}`);
console.log(`Switching plan for user: ${userId}`)
// Convert userId to ObjectId
const userObjectId = new Types.ObjectId(userId);
const userObjectId = new Types.ObjectId(userId)
let plan: PlanDocument
if (newPlanPolarProductId) {
plan = await this.planModel.findOne({
polarProductId: newPlanPolarProductId,
$or: [
// { polarProductId: newPlanPolarProductId }, // deprecated
{ polarMonthlyProductId: newPlanPolarProductId },
{ polarYearlyProductId: newPlanPolarProductId },
],
})
} else if (newPlanName) {
plan = await this.planModel.findOne({ name: newPlanName })
@@ -249,24 +235,33 @@ export class BillingService {
throw new Error('Plan not found')
}
console.log(`Found plan: ${plan.name}`);
console.log(`Found plan: ${plan.name}`)
// Deactivate current active subscriptions
const result = await this.subscriptionModel.updateMany(
{ user: userObjectId, plan: { $ne: plan._id }, isActive: true },
{ isActive: false, endDate: new Date() },
)
console.log(`Deactivated subscriptions: ${result.modifiedCount}`);
console.log(`Deactivated subscriptions: ${result.modifiedCount}`)
// Create or update the new subscription
const updateResult = await this.subscriptionModel.updateOne(
{ user: userObjectId, plan: plan._id },
{ isActive: true },
{
isActive: true,
currentPeriodStart,
currentPeriodEnd,
subscriptionStartDate,
subscriptionEndDate,
status,
},
{ upsert: true },
)
console.log(`Updated or created subscription: ${updateResult.upsertedCount > 0 ? 'Created' : 'Updated'}`);
console.log(
`Updated or created subscription: ${updateResult.upsertedCount > 0 ? 'Created' : 'Updated'}`,
)
return { success: true, plan: plan.name };
return { success: true, plan: plan.name }
}
async canPerformAction(
@@ -274,9 +269,6 @@ export class BillingService {
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
@@ -306,7 +298,7 @@ export class BillingService {
user: userId,
isActive: true,
})
if (!subscription) {
plan = await this.planModel.findOne({ name: 'free' })
} else {

View File

@@ -26,6 +26,24 @@ export class Plan {
@Prop({ type: String, unique: true })
polarProductId?: string
@Prop({ type: String, unique: true })
polarMonthlyProductId?: string
@Prop({ type: String, unique: true })
polarYearlyProductId?: string
@Prop({ type: Date })
subscriptionStartDate?: Date
@Prop({ type: Date })
subscriptionEndDate?: Date
@Prop({ type: Date })
currentPeriodStart?: Date
@Prop({ type: Date })
currentPeriodEnd?: Date
@Prop({ type: Boolean, default: true })
isActive: boolean
}

View File

@@ -18,7 +18,7 @@ import { SMSBatch } from './schemas/sms-batch.schema'
import {
BatchResponse,
Message,
} from 'firebase-admin/lib/messaging/messaging-api'
} from 'firebase-admin/messaging'
import { WebhookEvent } from 'src/webhook/webhook-event.enum'
import { WebhookService } from 'src/webhook/webhook.service'
import { BillingService } from 'src/billing/billing.service'

View File

@@ -1,12 +1,13 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",