From 674ad8e9a46ed18a0d2c932d31c8587a9b1d27f1 Mon Sep 17 00:00:00 2001 From: MiguelMLorente Date: Sun, 7 Dec 2025 14:37:39 +0100 Subject: [PATCH] Implement poller for stripe session statuses --- package-lock.json | 42 ++++++++++++++++++++ package.json | 1 + src/app.module.ts | 2 + src/controller/buy.controller.ts | 29 +------------- src/service/purchase.service.ts | 66 +++++++++++++++++++++++++++++--- src/service/stripe.service.ts | 1 + 6 files changed, 108 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index fec7848..c34ca7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.0.1", "@nestjs/typeorm": "^11.0.0", "async-lock": "^1.4.1", "bcrypt": "^6.0.0", @@ -2405,6 +2406,19 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.0.1.tgz", + "integrity": "sha512-v3yO6cSPAoBSSyH67HWnXHzuhPhSNZhRmLY38JvCt2sqY8sPMOODpcU1D79iUMFf7k16DaMEbL4Mgx61ZhiC8Q==", + "license": "MIT", + "dependencies": { + "cron": "4.3.3" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.9", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", @@ -2929,6 +2943,12 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -4892,6 +4912,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.3.tgz", + "integrity": "sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7717,6 +7750,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/package.json b/package.json index f5ad160..920cc89 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.0.1", "@nestjs/typeorm": "^11.0.0", "async-lock": "^1.4.1", "bcrypt": "^6.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index e19815f..1a34382 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { JwtModule } from '@nestjs/jwt'; import { AuthGuard } from './auth.guard'; import { AuthService } from './service/auth.service'; import { InvoiceController } from './controller/invoice.controller'; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { InvoiceController } from './controller/invoice.controller'; secret: process.env.JWT_SECRET || 'jwt-key', signOptions: { expiresIn: '4 WEEKS' }, }), + ScheduleModule.forRoot(), ], controllers: [ AccessController, diff --git a/src/controller/buy.controller.ts b/src/controller/buy.controller.ts index e7bb1de..ea68e58 100644 --- a/src/controller/buy.controller.ts +++ b/src/controller/buy.controller.ts @@ -10,23 +10,14 @@ import { PurchaseService } from '../service/purchase.service'; import { UserService } from '../service/user.service'; import { PurchaseItem } from '../dto/purchase-item'; import { StripeService } from '../service/stripe.service'; -import { PurchaseStatus } from 'src/dto/purchase-status'; -import { TokenService } from 'src/service/token.service'; -import AsyncLock from 'async-lock'; @Controller('/buy') export class BuyController { - private static PURCHASE_STATUS_TO_REEVALUATE = [ - PurchaseStatus.CREATED, - PurchaseStatus.IN_PROGRESS, - ]; private readonly logger = new Logger(BuyController.name); - private readonly purchaseLock = new AsyncLock(); constructor( private readonly purchaseService: PurchaseService, private readonly userService: UserService, - private readonly tokenService: TokenService, private readonly stripeService: StripeService, ) {} @@ -60,25 +51,7 @@ export class BuyController { } const userId = request.userId; const user = await this.userService.getUserById(userId); - await this.purchaseLock.acquire(purchaseId, async () => { - const purchase = await this.purchaseService.getPurchaseById(purchaseId); - if ( - !BuyController.PURCHASE_STATUS_TO_REEVALUATE.includes(purchase.status) - ) { - this.logger.debug('Purchase is not open to re-evaluate'); - return; - } - const { isPaid } = await this.stripeService.getStripeSessionFromId( - purchase.stripeSessionId!, - ); - purchase.status = isPaid - ? PurchaseStatus.COMPLETED - : PurchaseStatus.IN_PROGRESS; - await this.purchaseService.recordPurchase(purchase); - if (purchase.status === PurchaseStatus.COMPLETED) { - await this.tokenService.addTokens(user, purchase); - } - }); + await this.purchaseService.tryCompletePurchase(user, purchaseId); return { url: '/signup' }; } diff --git a/src/service/purchase.service.ts b/src/service/purchase.service.ts index b140d9f..005f8cc 100644 --- a/src/service/purchase.service.ts +++ b/src/service/purchase.service.ts @@ -4,14 +4,27 @@ import { Purchase } from 'src/dto/purchase'; import { PurchaseItem } from 'src/dto/purchase-item'; import { PurchaseStatus } from 'src/dto/purchase-status'; import { User } from 'src/dto/user'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; +import AsyncLock from 'async-lock'; +import { TokenService } from './token.service'; +import { StripeService } from './stripe.service'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { UserService } from './user.service'; @Injectable() export class PurchaseService { private readonly logger = new Logger(PurchaseService.name); + private readonly purchaseLock = new AsyncLock(); + private static PURCHASE_STATUS_TO_REEVALUATE = [ + PurchaseStatus.CREATED, + PurchaseStatus.IN_PROGRESS, + ]; constructor( @InjectRepository(Purchase) private purchaseRepo: Repository, + private readonly tokenService: TokenService, + private readonly stripeService: StripeService, + private readonly userService: UserService, ) {} public createPurchase( @@ -34,11 +47,54 @@ export class PurchaseService { await this.purchaseRepo.save(purchase); } - public async getPurchaseById(purchaseId: string) { - return this.purchaseRepo.findOneOrFail({ where: { id: purchaseId } }); - } - public getPurchasesForUser(user: User) { return this.purchaseRepo.find({ where: { purchasedBy: user } }); } + + @Cron(CronExpression.EVERY_2_HOURS) + private async tryCompleteAllPendingPurchases() { + const purchases = await this.purchaseRepo.find({ + where: { status: In(PurchaseService.PURCHASE_STATUS_TO_REEVALUATE) }, + }); + await Promise.all( + purchases.map(async (purchase) => { + const user = await this.userService.getUserById( + purchase.purchasedBy.id, + ); + await this.tryCompletePurchase(user, purchase.id); + }), + ); + } + + public async tryCompletePurchase(user: User, purchaseId: string) { + this.purchaseLock.acquire(purchaseId, async () => { + const purchase = await this.purchaseRepo.findOne({ + where: { + id: purchaseId, + status: In(PurchaseService.PURCHASE_STATUS_TO_REEVALUATE), + }, + }); + if (!purchase) { + this.logger.debug('Purchase is not open to re-evaluate'); + return; + } + const { isPaid, sessionStatus } = + await this.stripeService.getStripeSessionFromId( + purchase.stripeSessionId!, + ); + + if (sessionStatus === 'complete' && isPaid) { + purchase.status = PurchaseStatus.COMPLETED; + } else if (sessionStatus === 'complete' || sessionStatus === 'expired') { + purchase.status = PurchaseStatus.FAILED; + } else if (sessionStatus === 'open') { + purchase.status = PurchaseStatus.IN_PROGRESS; + } + + await this.recordPurchase(purchase); + if (purchase.status === PurchaseStatus.COMPLETED) { + await this.tokenService.addTokens(user, purchase); + } + }); + } } diff --git a/src/service/stripe.service.ts b/src/service/stripe.service.ts index 4c95e63..8390b44 100644 --- a/src/service/stripe.service.ts +++ b/src/service/stripe.service.ts @@ -37,6 +37,7 @@ export class StripeService { const session = await this.stripe.checkout.sessions.retrieve(sessionId); return { isPaid: session.payment_status === 'paid', + sessionStatus: session.status, }; } }