From 99efd20e7aa6f2fb28d0c7d75d35212c1243ebad Mon Sep 17 00:00:00 2001 From: isra el Date: Mon, 4 Aug 2025 08:40:03 +0300 Subject: [PATCH] fix(api): prevent duplicate checkout sessions per user --- api/package.json | 2 +- api/pnpm-lock.yaml | 107 +++++++++++------- api/src/billing/billing.module.ts | 2 + api/src/billing/billing.service.ts | 51 +++++++-- .../schemas/checkout-session.schema.ts | 27 +++++ 5 files changed, 138 insertions(+), 51 deletions(-) create mode 100644 api/src/billing/schemas/checkout-session.schema.ts diff --git a/api/package.json b/api/package.json index 42a8380..84e21bc 100644 --- a/api/package.json +++ b/api/package.json @@ -33,7 +33,7 @@ "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^7.4.2", "@nestjs/throttler": "^6.2.1", - "@polar-sh/sdk": "^0.32.3", + "@polar-sh/sdk": "^0.34.9", "axios": "^1.8.2", "bcryptjs": "^2.4.3", "bull": "^4.16.5", diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 78b3e20..95e4ae3 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^6.2.1 version: 6.2.1(@nestjs/common@10.4.5(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(reflect-metadata@0.2.2) '@polar-sh/sdk': - specifier: ^0.32.3 - version: 0.32.3(zod@3.24.1) + specifier: ^0.34.9 + version: 0.34.9 axios: specifier: ^1.8.2 version: 1.8.2 @@ -134,19 +134,19 @@ importers: version: 6.0.2 '@typescript-eslint/eslint-plugin': specifier: ^8.10.0 - version: 8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0)(typescript@5.6.3))(eslint@9.13.0)(typescript@5.6.3) + version: 8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3))(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3) '@typescript-eslint/parser': specifier: ^8.10.0 - version: 8.10.0(eslint@9.13.0)(typescript@5.6.3) + version: 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3) eslint: specifier: ^9.13.0 - version: 9.13.0 + version: 9.13.0(jiti@2.4.2) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.0(eslint@9.13.0) + version: 9.1.0(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-prettier: specifier: ^5.2.1 - version: 5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.13.0))(eslint@9.13.0)(prettier@3.3.3) + version: 5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))(prettier@3.3.3) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.7.7)(ts-node@10.9.2(@types/node@22.7.7)(typescript@5.6.3)) @@ -317,6 +317,10 @@ packages: resolution: {integrity: sha512-gYq0xCsqFfQaSL/yT1Gl1vIUjtsg7d7RhnUfsXaHt8xTxOKRTdH9GjbesBjXOzgOvB0W0vfssfreSNGFlOOMJg==} engines: {node: '>=16.0.0'} + '@aws-sdk/types@3.840.0': + resolution: {integrity: sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/util-endpoints@3.667.0': resolution: {integrity: sha512-X22SYDAuQJWnkF1/q17pkX3nGw5XMD9YEUbmt87vUnRq7iyJ3JOpl6UKOBeUBaL838wA5yzdbinmCITJ/VZ1QA==} engines: {node: '>=16.0.0'} @@ -975,12 +979,11 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@polar-sh/sdk@0.32.3': - resolution: {integrity: sha512-AW4CNIVIkLYmCy59ynkxGYz0/1m1w/L7WIC7+m0SW5j+k5HrLgTYK7/7jdwu8biddT5EcQOvmjTnHYtLFFs9sA==} + '@polar-sh/sdk@0.34.9': + resolution: {integrity: sha512-+kzQ3IlO67+/eSC4kmbZbmqdPjmvAr8zjTsavyx/u5lqwrerutvhpDzCCBQMlFnEVgeXArF+Rf6FQKkqKrgpxA==} hasBin: true peerDependencies: - '@modelcontextprotocol/sdk': ^1.5.0 - zod: '>= 3' + '@modelcontextprotocol/sdk': '>=1.5.0 <1.10.0' peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true @@ -1122,6 +1125,10 @@ packages: resolution: {integrity: sha512-QN0twHNfe8mNJdH9unwsCK13GURU7oEAZqkBI+rsvpv1jrmserO+WnLE7jidR9W/1dxwZ0u/CB01mV2Gms/K2Q==} engines: {node: '>=16.0.0'} + '@smithy/types@4.3.1': + resolution: {integrity: sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@3.0.7': resolution: {integrity: sha512-70UbSSR8J97c1rHZOWhl+VKiZDqHWxs/iW8ZHrHp5fCCPLSBE7GcUlUvKSle3Ca+J9LLbYCj/A79BxztBvAfpA==} @@ -2919,6 +2926,10 @@ packages: node-notifier: optional: true + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -4387,8 +4398,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.24.1: - resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: @@ -4455,7 +4466,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.667.0 + '@aws-sdk/types': 3.840.0 '@aws-sdk/util-locate-window': 3.568.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -4464,7 +4475,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.667.0 + '@aws-sdk/types': 3.840.0 tslib: 2.8.1 optional: true @@ -4475,7 +4486,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.667.0 + '@aws-sdk/types': 3.840.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 optional: true @@ -4871,6 +4882,12 @@ snapshots: tslib: 2.8.1 optional: true + '@aws-sdk/types@3.840.0': + dependencies: + '@smithy/types': 4.3.1 + tslib: 2.8.1 + optional: true + '@aws-sdk/util-endpoints@3.667.0': dependencies: '@aws-sdk/types': 3.667.0 @@ -5109,9 +5126,9 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@eslint-community/eslint-utils@4.4.0(eslint@9.13.0)': + '@eslint-community/eslint-utils@4.4.0(eslint@9.13.0(jiti@2.4.2))': dependencies: - eslint: 9.13.0 + eslint: 9.13.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.11.1': {} @@ -5725,10 +5742,10 @@ snapshots: '@pkgr/core@0.1.1': {} - '@polar-sh/sdk@0.32.3(zod@3.24.1)': + '@polar-sh/sdk@0.34.9': dependencies: standardwebhooks: 1.0.0 - zod: 3.24.1 + zod: 3.25.76 '@protobufjs/aspromise@1.1.2': optional: true @@ -5967,6 +5984,11 @@ snapshots: tslib: 2.8.1 optional: true + '@smithy/types@4.3.1': + dependencies: + tslib: 2.8.1 + optional: true + '@smithy/url-parser@3.0.7': dependencies: '@smithy/querystring-parser': 3.0.7 @@ -6292,15 +6314,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0)(typescript@5.6.3))(eslint@9.13.0)(typescript@5.6.3)': + '@typescript-eslint/eslint-plugin@8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3))(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.11.1 - '@typescript-eslint/parser': 8.10.0(eslint@9.13.0)(typescript@5.6.3) + '@typescript-eslint/parser': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3) '@typescript-eslint/scope-manager': 8.10.0 - '@typescript-eslint/type-utils': 8.10.0(eslint@9.13.0)(typescript@5.6.3) - '@typescript-eslint/utils': 8.10.0(eslint@9.13.0)(typescript@5.6.3) + '@typescript-eslint/type-utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3) + '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3) '@typescript-eslint/visitor-keys': 8.10.0 - eslint: 9.13.0 + eslint: 9.13.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -6310,14 +6332,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.10.0(eslint@9.13.0)(typescript@5.6.3)': + '@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3)': dependencies: '@typescript-eslint/scope-manager': 8.10.0 '@typescript-eslint/types': 8.10.0 '@typescript-eslint/typescript-estree': 8.10.0(typescript@5.6.3) '@typescript-eslint/visitor-keys': 8.10.0 debug: 4.3.7 - eslint: 9.13.0 + eslint: 9.13.0(jiti@2.4.2) optionalDependencies: typescript: 5.6.3 transitivePeerDependencies: @@ -6328,10 +6350,10 @@ snapshots: '@typescript-eslint/types': 8.10.0 '@typescript-eslint/visitor-keys': 8.10.0 - '@typescript-eslint/type-utils@8.10.0(eslint@9.13.0)(typescript@5.6.3)': + '@typescript-eslint/type-utils@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3)': dependencies: '@typescript-eslint/typescript-estree': 8.10.0(typescript@5.6.3) - '@typescript-eslint/utils': 8.10.0(eslint@9.13.0)(typescript@5.6.3) + '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3) debug: 4.3.7 ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: @@ -6357,13 +6379,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.10.0(eslint@9.13.0)(typescript@5.6.3)': + '@typescript-eslint/utils@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.6.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.10.0 '@typescript-eslint/types': 8.10.0 '@typescript-eslint/typescript-estree': 8.10.0(typescript@5.6.3) - eslint: 9.13.0 + eslint: 9.13.0(jiti@2.4.2) transitivePeerDependencies: - supports-color - typescript @@ -7196,19 +7218,19 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@9.1.0(eslint@9.13.0): + eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0 + eslint: 9.13.0(jiti@2.4.2) - eslint-plugin-prettier@5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.13.0))(eslint@9.13.0)(prettier@3.3.3): + eslint-plugin-prettier@5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))(prettier@3.3.3): dependencies: - eslint: 9.13.0 + eslint: 9.13.0(jiti@2.4.2) prettier: 3.3.3 prettier-linter-helpers: 1.0.0 synckit: 0.9.2 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 9.1.0(eslint@9.13.0) + eslint-config-prettier: 9.1.0(eslint@9.13.0(jiti@2.4.2)) eslint-scope@5.1.1: dependencies: @@ -7224,9 +7246,9 @@ snapshots: eslint-visitor-keys@4.1.0: {} - eslint@9.13.0: + eslint@9.13.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.11.1 '@eslint/config-array': 0.18.0 '@eslint/core': 0.7.0 @@ -7261,6 +7283,8 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 text-table: 0.2.0 + optionalDependencies: + jiti: 2.4.2 transitivePeerDependencies: - supports-color @@ -8373,6 +8397,9 @@ snapshots: - supports-color - ts-node + jiti@2.4.2: + optional: true + jose@4.15.9: {} js-stringify@1.0.2: @@ -9894,4 +9921,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.24.1: {} + zod@3.25.76: {} diff --git a/api/src/billing/billing.module.ts b/api/src/billing/billing.module.ts index 48c4da2..b4acefa 100644 --- a/api/src/billing/billing.module.ts +++ b/api/src/billing/billing.module.ts @@ -11,6 +11,7 @@ import { UsersModule } from 'src/users/users.module' import { GatewayModule } from 'src/gateway/gateway.module' import { PolarWebhookPayload, PolarWebhookPayloadSchema } from './schemas/polar-webhook-payload.schema' import { Device, DeviceSchema } from '../gateway/schemas/device.schema' +import { CheckoutSession, CheckoutSessionSchema } from './schemas/checkout-session.schema' @Module({ imports: [ @@ -19,6 +20,7 @@ import { Device, DeviceSchema } from '../gateway/schemas/device.schema' { name: Subscription.name, schema: SubscriptionSchema }, { name: PolarWebhookPayload.name, schema: PolarWebhookPayloadSchema }, { name: Device.name, schema: DeviceSchema }, + { name: CheckoutSession.name, schema: CheckoutSessionSchema }, ]), forwardRef(() => AuthModule), forwardRef(() => UsersModule), diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 71e1642..c1f804f 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common' +import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model, Types } from 'mongoose' import { Plan, PlanDocument } from './schemas/plan.schema' @@ -17,6 +17,7 @@ import { PolarWebhookPayload, PolarWebhookPayloadDocument, } from './schemas/polar-webhook-payload.schema' +import { CheckoutSession, CheckoutSessionDocument } from './schemas/checkout-session.schema' @Injectable() export class BillingService { @@ -31,6 +32,8 @@ export class BillingService { @InjectModel(Device.name) private deviceModel: Model, @InjectModel(PolarWebhookPayload.name) private polarWebhookPayloadModel: Model, + @InjectModel(CheckoutSession.name) + private checkoutSessionModel: Model, ) { this.polarApi = new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN ?? '', @@ -115,6 +118,15 @@ export class BillingService { }): Promise { const isYearly = payload.isYearly + const existingCheckoutSession = await this.checkoutSessionModel.findOne({ + user: user._id, + expiresAt: { $gt: new Date() }, + }) + + if (existingCheckoutSession) { + return { redirectUrl: existingCheckoutSession.checkoutUrl } + } + const selectedPlan = await this.planModel.findOne({ name: payload.planName, }) @@ -123,13 +135,12 @@ export class BillingService { !selectedPlan?.polarMonthlyProductId && !selectedPlan?.polarYearlyProductId ) { - throw new Error('Plan cannot be purchased') + throw new BadRequestException('Plan cannot be purchased') } // const product = await this.polarApi.products.get(selectedPlan.polarProductId) - const discountId = - payload.discountId ?? '48f62ff7-3cd8-46ec-8ca7-2e570dc9c522' + const discountId = payload.discountId ?? process.env.POLAR_DEFAULT_DISCOUNT_ID try { const checkoutOptions: any = { @@ -146,16 +157,36 @@ export class BillingService { metadata: { userId: user._id?.toString(), }, + externalCustomerId: user._id?.toString(), } - const discount = await this.polarApi.discounts.get({ - id: discountId, - }) - if (discount) { - checkoutOptions.discountId = discount.id + + try { + const discount = await this.polarApi.discounts.get({ + id: discountId, + }) + if (discount) { + checkoutOptions.discountId = discount.id + } + } catch (error) { + console.error('failed to get discount', error) } + const checkout = await this.polarApi.checkouts.create(checkoutOptions) - console.log(checkout) + + + this.checkoutSessionModel.updateOne({ + user: user._id, + },{ + user: user._id, + checkoutSessionId: checkout.id, + checkoutUrl: checkout.url, + expiresAt: new Date(checkout.expiresAt), + payload: checkout, + }, { upsert: true }).catch((error) => { + console.error(error) + }) + return { redirectUrl: checkout.url } } catch (error) { console.error(error) diff --git a/api/src/billing/schemas/checkout-session.schema.ts b/api/src/billing/schemas/checkout-session.schema.ts new file mode 100644 index 0000000..6ffdd29 --- /dev/null +++ b/api/src/billing/schemas/checkout-session.schema.ts @@ -0,0 +1,27 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Document, Types } from 'mongoose' +import { User } from 'src/users/schemas/user.schema' + +export type CheckoutSessionDocument = CheckoutSession & Document + +@Schema({ timestamps: true }) +export class CheckoutSession { + _id?: Types.ObjectId + + @Prop({ type: Types.ObjectId, ref: User.name, required: true }) + user: User + + @Prop({ type: String, required: true }) + checkoutSessionId: string + + @Prop({ type: String, required: true }) + checkoutUrl: string + + @Prop({ type: Date, required: true }) + expiresAt: Date + + @Prop({ type: Object, required: true, default: {} }) + payload: any +} + +export const CheckoutSessionSchema = SchemaFactory.createForClass(CheckoutSession)