Implement poller for stripe session statuses
parent
1fd8bfec89
commit
674ad8e9a4
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue