chore: add queue for the discount invalidation

This commit is contained in:
mahyargdz
2025-04-19 16:16:37 +03:30
parent 53aaef4c81
commit 1567d61aca
6 changed files with 142 additions and 5 deletions
+3 -1
View File
@@ -475,7 +475,8 @@ export const enum InvoiceMessage {
INVALID_DISCOUNT_CODE = "کد تخفیف نامعتبر است",
ORIGINAL_PRICE_NOT_FOUND = "قیمت اصلی صورت حساب یافت نشد",
NOT_FOUND_BY_ID_OR_NOT_BELONG_TO_USER = "صورت حساب یافت نشد یا متعلق به کاربر نمی باشد",
DISCOUNT_ALREADY_APPLIED = "تخفیف قبلا برای این صورت حساب اعمال شده است",
DISCOUNT_ALREADY_APPLIED = `خفیف قبلا برای این صورت حساب اعمال شده است",
DISCOUNT_EXPIRED = "تخفیف منقضی شده است",
}
export const enum LearningMessage {
@@ -530,6 +531,7 @@ export const enum DiscountMessage {
EACH_TARGET_PRODUCT_MUST_BE_A_UUID = "هر محصول باید یک UUID معتبر باشد",
DIRECT_DISCOUNT_ALREADY_EXISTS = "تخفیف مستقیم قبلا برای سرویس [serviceName] ثبت شده است",
UPDATED = "تخفیف با موفقیت بروزرسانی شد",
DEACTIVATED = "تخفیف با موفقیت غیرفعال شد",
}
export const enum EmailMessage {
+10
View File
@@ -0,0 +1,10 @@
export const DISCOUNT = Object.freeze({
DISCOUNT_QUEUE_NAME: "discount",
DISCOUNT_DEACTIVATE_JOB_NAME: "deactivateDiscount",
//
DISCOUNT_DEACTIVATE_JOB_PRIORITY: 1, // high priority
DISCOUNT_DEACTIVATE_JOB_ATTEMPTS: 3, // 3 times
DISCOUNT_DEACTIVATE_JOB_BACKOFF: 5 * 1000, // ms
DISCOUNT_DEACTIVATE_JOB_TIMEOUT: 10000, // timeout after 10 seconds
//
});
+10 -2
View File
@@ -1,6 +1,8 @@
import { BullModule } from "@nestjs/bullmq";
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { DISCOUNT } from "./constants";
import { DiscountsController } from "./discounts.controller";
import { UsersModule } from "../users/users.module";
import { Discount } from "./entities/discount.entity";
@@ -9,10 +11,16 @@ import { DiscountsService } from "./providers/discounts.service";
import { DiscountRepository } from "./repositories/discounts.repository";
import { UsageDiscountRepository } from "./repositories/usage-discounts.repository";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { DiscountProcessor } from "./queue/discount.processor";
@Module({
imports: [TypeOrmModule.forFeature([Discount, UsageDiscount]), UsersModule, SubscriptionsModule],
imports: [
TypeOrmModule.forFeature([Discount, UsageDiscount]),
BullModule.registerQueue({ name: DISCOUNT.DISCOUNT_QUEUE_NAME }),
UsersModule,
SubscriptionsModule,
],
controllers: [DiscountsController],
providers: [DiscountsService, DiscountRepository, UsageDiscountRepository],
providers: [DiscountsService, DiscountRepository, UsageDiscountRepository, DiscountProcessor],
exports: [DiscountsService],
})
export class DiscountsModule {}
@@ -1,6 +1,8 @@
import { randomBytes } from "node:crypto";
import { InjectQueue } from "@nestjs/bullmq";
import { BadRequestException, Injectable } from "@nestjs/common";
import { Queue } from "bullmq";
import dayjs from "dayjs";
import { Decimal } from "decimal.js";
import { DataSource, QueryRunner } from "typeorm";
@@ -8,6 +10,7 @@ import { DataSource, QueryRunner } from "typeorm";
import { DiscountMessage } from "../../../common/enums/message.enum";
import { SubscriptionPlan } from "../../subscriptions/entities/subscription.entity";
import { SubscriptionsService } from "../../subscriptions/providers/subscriptions.service";
import { DISCOUNT } from "../constants";
import { CreateDiscountDto } from "../DTO/create-discount.dto";
import { SearchDiscountQueryDto } from "../DTO/search-discount-query.dto";
import { UpdateDiscountDto } from "../DTO/update-discount.dto";
@@ -21,6 +24,7 @@ export class DiscountsService {
private MAX_ATTEMPTS = 10;
constructor(
@InjectQueue(DISCOUNT.DISCOUNT_QUEUE_NAME) private readonly discountQueue: Queue,
private readonly discountRepository: DiscountRepository,
private readonly subscriptionService: SubscriptionsService,
private readonly dataSource: DataSource,
@@ -37,6 +41,7 @@ export class DiscountsService {
const discount = await this.createDiscount(createDiscountDto, queryRunner);
await this.addDiscountDeactivationJob(discount.id, createDiscountDto.endDate);
await queryRunner.commitTransaction();
return {
@@ -76,8 +81,11 @@ export class DiscountsService {
if (!existingDiscount) throw new BadRequestException(DiscountMessage.NOT_FOUND);
if (updateDiscountDto.startDate && updateDiscountDto.endDate)
if (updateDiscountDto.startDate && updateDiscountDto.endDate) {
await this.validateDates(updateDiscountDto.startDate, updateDiscountDto.endDate);
await this.removeDiscountDeactivationJob(existingDiscount.id);
await this.addDiscountDeactivationJob(existingDiscount.id, updateDiscountDto.endDate);
}
if (updateDiscountDto.value) existingDiscount.value = updateDiscountDto.value;
@@ -109,6 +117,37 @@ export class DiscountsService {
await this.discountRepository.softDeleteDiscount(id);
}
//************************************ */
async deactivateDiscount(id: string, queryRunner: QueryRunner) {
const discount = await queryRunner.manager.findOne(this.discountRepository.target, {
where: { id, isActive: true },
relations: {
subscriptionPlans: true,
},
withDeleted: false,
});
if (!discount) throw new BadRequestException(DiscountMessage.NOT_FOUND);
discount.isActive = false;
await queryRunner.manager.save(discount);
const subscriptionPlans = discount.subscriptionPlans;
if (discount.applicationType === DiscountApplicationType.DIRECT) {
for (const subscriptionPlan of subscriptionPlans) {
subscriptionPlan.directDiscount = undefined;
subscriptionPlan.price = subscriptionPlan.originalPrice
? new Decimal(subscriptionPlan.originalPrice)
: new Decimal(subscriptionPlan.price);
await queryRunner.manager.save(subscriptionPlan);
}
}
return {
message: DiscountMessage.DEACTIVATED,
discount,
};
}
//************************************ */
private async createDiscount(createDiscountDto: CreateDiscountDto, queryRunner: QueryRunner) {
const discount = queryRunner.manager.create(this.discountRepository.target, {
...createDiscountDto,
@@ -171,7 +210,7 @@ export class DiscountsService {
private async findDiscountByCode(code: string, queryRunner: QueryRunner) {
return queryRunner.manager.findOne(this.discountRepository.target, {
where: { code },
where: { code, isActive: true },
withDeleted: false,
});
}
@@ -253,4 +292,28 @@ export class DiscountsService {
}
subscriptionPlan.price = subscriptionPlan.originalPrice.sub(discountAmount);
}
//************************************ */
private async removeDiscountDeactivationJob(discountId: string) {
const jobs = await this.discountQueue.getJobs(["delayed", "waiting", "active"]);
const jobToRemove = jobs.find((job) => job.name === DISCOUNT.DISCOUNT_DEACTIVATE_JOB_NAME && job.data.discountId === discountId);
if (jobToRemove) await jobToRemove.remove();
}
//************************************ */
private async addDiscountDeactivationJob(discountId: string, endDate: string) {
return this.discountQueue.add(
DISCOUNT.DISCOUNT_DEACTIVATE_JOB_NAME,
{
discountId,
},
{
delay: dayjs(endDate).diff(dayjs(), "ms"),
priority: DISCOUNT.DISCOUNT_DEACTIVATE_JOB_PRIORITY,
attempts: DISCOUNT.DISCOUNT_DEACTIVATE_JOB_ATTEMPTS,
backoff: {
type: "exponential",
delay: DISCOUNT.DISCOUNT_DEACTIVATE_JOB_BACKOFF,
},
},
);
}
}
@@ -0,0 +1,52 @@
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { DataSource } from "typeorm";
import { DISCOUNT } from "../constants";
import { DiscountsService } from "../providers/discounts.service";
@Processor(DISCOUNT.DISCOUNT_QUEUE_NAME)
export class DiscountProcessor extends WorkerHost {
private readonly logger = new Logger(DiscountProcessor.name);
constructor(
private readonly discountService: DiscountsService,
private readonly dataSource: DataSource,
) {
super();
}
async process(job: Job) {
switch (job.name) {
case DISCOUNT.DISCOUNT_DEACTIVATE_JOB_NAME:
await this.deactivateDiscount(job);
break;
default:
this.logger.warn(`Unknown job name: ${job.name}`);
break;
}
}
async deactivateDiscount(job: Job) {
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const { discountId } = job.data;
const discount = await this.discountService.deactivateDiscount(discountId, queryRunner);
await queryRunner.commitTransaction();
this.logger.log(`Discount ${discount.discount.id} deactivated`);
return discount;
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Error deactivating discount ${job.data.discountId}: ${error instanceof Error ? error.message : "unknown error"}`);
throw error;
} finally {
await queryRunner.release();
}
}
}
@@ -584,6 +584,8 @@ export class InvoicesService {
if (!discount) throw new BadRequestException(InvoiceMessage.INVALID_DISCOUNT_CODE);
if (discount.endDate && dayjs(discount.endDate).isBefore(dayjs())) throw new BadRequestException(InvoiceMessage.DISCOUNT_EXPIRED);
if (!invoice.originalPrice) invoice.originalPrice = new Decimal(invoice.totalPrice);
let discountAmount: Decimal;