Implement poller for stripe session statuses

main
MiguelMLorente 2025-12-07 14:37:39 +01:00
parent 1fd8bfec89
commit 674ad8e9a4
6 changed files with 108 additions and 33 deletions

42
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"async-lock": "^1.4.1", "async-lock": "^1.4.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@ -2405,6 +2406,19 @@
"@nestjs/core": "^11.0.0" "@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": { "node_modules/@nestjs/schematics": {
"version": "11.0.9", "version": "11.0.9",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz",
@ -2929,6 +2943,12 @@
"@types/node": "*" "@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": { "node_modules/@types/methods": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@ -4892,6 +4912,19 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -7717,6 +7750,15 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",

View File

@ -19,6 +19,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"async-lock": "^1.4.1", "async-lock": "^1.4.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",

View File

@ -17,6 +17,7 @@ import { JwtModule } from '@nestjs/jwt';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { AuthService } from './service/auth.service'; import { AuthService } from './service/auth.service';
import { InvoiceController } from './controller/invoice.controller'; import { InvoiceController } from './controller/invoice.controller';
import { ScheduleModule } from '@nestjs/schedule';
@Module({ @Module({
imports: [ imports: [
@ -37,6 +38,7 @@ import { InvoiceController } from './controller/invoice.controller';
secret: process.env.JWT_SECRET || 'jwt-key', secret: process.env.JWT_SECRET || 'jwt-key',
signOptions: { expiresIn: '4 WEEKS' }, signOptions: { expiresIn: '4 WEEKS' },
}), }),
ScheduleModule.forRoot(),
], ],
controllers: [ controllers: [
AccessController, AccessController,

View File

@ -10,23 +10,14 @@ import { PurchaseService } from '../service/purchase.service';
import { UserService } from '../service/user.service'; import { UserService } from '../service/user.service';
import { PurchaseItem } from '../dto/purchase-item'; import { PurchaseItem } from '../dto/purchase-item';
import { StripeService } from '../service/stripe.service'; 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') @Controller('/buy')
export class BuyController { export class BuyController {
private static PURCHASE_STATUS_TO_REEVALUATE = [
PurchaseStatus.CREATED,
PurchaseStatus.IN_PROGRESS,
];
private readonly logger = new Logger(BuyController.name); private readonly logger = new Logger(BuyController.name);
private readonly purchaseLock = new AsyncLock();
constructor( constructor(
private readonly purchaseService: PurchaseService, private readonly purchaseService: PurchaseService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly tokenService: TokenService,
private readonly stripeService: StripeService, private readonly stripeService: StripeService,
) {} ) {}
@ -60,25 +51,7 @@ export class BuyController {
} }
const userId = request.userId; const userId = request.userId;
const user = await this.userService.getUserById(userId); const user = await this.userService.getUserById(userId);
await this.purchaseLock.acquire(purchaseId, async () => { await this.purchaseService.tryCompletePurchase(user, purchaseId);
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);
}
});
return { url: '/signup' }; return { url: '/signup' };
} }

View File

@ -4,14 +4,27 @@ import { Purchase } from 'src/dto/purchase';
import { PurchaseItem } from 'src/dto/purchase-item'; import { PurchaseItem } from 'src/dto/purchase-item';
import { PurchaseStatus } from 'src/dto/purchase-status'; import { PurchaseStatus } from 'src/dto/purchase-status';
import { User } from 'src/dto/user'; 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() @Injectable()
export class PurchaseService { export class PurchaseService {
private readonly logger = new Logger(PurchaseService.name); private readonly logger = new Logger(PurchaseService.name);
private readonly purchaseLock = new AsyncLock();
private static PURCHASE_STATUS_TO_REEVALUATE = [
PurchaseStatus.CREATED,
PurchaseStatus.IN_PROGRESS,
];
constructor( constructor(
@InjectRepository(Purchase) private purchaseRepo: Repository<Purchase>, @InjectRepository(Purchase) private purchaseRepo: Repository<Purchase>,
private readonly tokenService: TokenService,
private readonly stripeService: StripeService,
private readonly userService: UserService,
) {} ) {}
public createPurchase( public createPurchase(
@ -34,11 +47,54 @@ export class PurchaseService {
await this.purchaseRepo.save(purchase); await this.purchaseRepo.save(purchase);
} }
public async getPurchaseById(purchaseId: string) {
return this.purchaseRepo.findOneOrFail({ where: { id: purchaseId } });
}
public getPurchasesForUser(user: User) { public getPurchasesForUser(user: User) {
return this.purchaseRepo.find({ where: { purchasedBy: 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);
}
});
}
} }

View File

@ -37,6 +37,7 @@ export class StripeService {
const session = await this.stripe.checkout.sessions.retrieve(sessionId); const session = await this.stripe.checkout.sessions.retrieve(sessionId);
return { return {
isPaid: session.payment_status === 'paid', isPaid: session.payment_status === 'paid',
sessionStatus: session.status,
}; };
} }
} }