diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 48f1f38..5b42db8 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -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 { diff --git a/src/modules/discounts/constants/index.ts b/src/modules/discounts/constants/index.ts new file mode 100644 index 0000000..f842723 --- /dev/null +++ b/src/modules/discounts/constants/index.ts @@ -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 + // +}); diff --git a/src/modules/discounts/discounts.module.ts b/src/modules/discounts/discounts.module.ts index f355d35..3d0b730 100755 --- a/src/modules/discounts/discounts.module.ts +++ b/src/modules/discounts/discounts.module.ts @@ -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 {} diff --git a/src/modules/discounts/providers/discounts.service.ts b/src/modules/discounts/providers/discounts.service.ts index 15a3ee4..dc09420 100755 --- a/src/modules/discounts/providers/discounts.service.ts +++ b/src/modules/discounts/providers/discounts.service.ts @@ -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, + }, + }, + ); + } } diff --git a/src/modules/discounts/queue/discount.processor.ts b/src/modules/discounts/queue/discount.processor.ts new file mode 100644 index 0000000..349e3f7 --- /dev/null +++ b/src/modules/discounts/queue/discount.processor.ts @@ -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(); + } + } +} diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index fa8c158..c667be2 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -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;