chore: add queue for the discount invalidation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
//
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user