Introduce mutex for purchase fulfillment locking

main
MiguelMLorente 2025-12-07 14:08:51 +01:00
parent 4a763acb48
commit 1fd8bfec89
4 changed files with 51 additions and 22 deletions

7
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"async-lock": "^1.4.1",
"bcrypt": "^6.0.0",
"dotenv": "^17.2.3",
"pg": "^8.16.3",
@ -4025,6 +4026,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/async-lock": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
"integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",

View File

@ -20,6 +20,7 @@
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"async-lock": "^1.4.1",
"bcrypt": "^6.0.0",
"dotenv": "^17.2.3",
"pg": "^8.16.3",

View File

@ -1,10 +1,18 @@
import { Body, Controller, Logger, Post, Req } from '@nestjs/common';
import {
Body,
Controller,
Logger,
NotFoundException,
Post,
Req,
} from '@nestjs/common';
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 {
@ -13,6 +21,7 @@ export class BuyController {
PurchaseStatus.IN_PROGRESS,
];
private readonly logger = new Logger(BuyController.name);
private readonly purchaseLock = new AsyncLock();
constructor(
private readonly purchaseService: PurchaseService,
@ -42,24 +51,32 @@ export class BuyController {
}
@Post('/complete')
public async complete(@Req() request) {
public async complete(
@Req() request,
@Body('purchaseId') purchaseId: string,
) {
if (!purchaseId) {
throw new NotFoundException();
}
const userId = request.userId;
const user = await this.userService.getUserById(userId);
const purchases = await this.purchaseService.getPurchasesForUser(user);
purchases
.filter((purchase) =>
BuyController.PURCHASE_STATUS_TO_REEVALUATE.includes(purchase.status),
)
.forEach(async (purchase) => {
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;
this.purchaseService.recordPurchase(purchase);
await this.purchaseService.recordPurchase(purchase);
if (purchase.status === PurchaseStatus.COMPLETED) {
this.tokenService.addTokens(user, purchase);
await this.tokenService.addTokens(user, purchase);
}
});

View File

@ -25,13 +25,17 @@ export class PurchaseService {
purchasedProduct: purchasedItem,
purchasedUnits: units,
status: PurchaseStatus.CREATED,
purchaseDate: Date.now(),
purchaseDate: new Date(),
});
return this.purchaseRepo.save(purchase);
}
public recordPurchase(purchase: Purchase) {
this.purchaseRepo.save(purchase);
public async recordPurchase(purchase: Purchase) {
await this.purchaseRepo.save(purchase);
}
public async getPurchaseById(purchaseId: string) {
return this.purchaseRepo.findOneOrFail({ where: { id: purchaseId } });
}
public getPurchasesForUser(user: User) {